use crate::acceptance::SUPPORTED_BLOCKER_CATEGORIES;
use crate::runtime::proposal::{BlockerOrigin, ExternalBlockerInfo};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalBlockerClaim {
pub origin: BlockerOrigin,
pub category: String,
pub evidence: Vec<String>,
pub prerequisite_owner: Option<String>,
pub unblock_condition: Option<String>,
pub next_action: String,
pub resumable: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExecutionStopReason {
RepeatedFinding,
NoSemanticProgress,
RetryExhausted,
PermissionDenial,
}
impl ExecutionStopReason {
pub fn as_str(self) -> &'static str {
match self {
Self::RepeatedFinding => "repeated_acceptance_findings",
Self::NoSemanticProgress => "no_semantic_progress",
Self::RetryExhausted => "retry_budget_exhausted",
Self::PermissionDenial => "permission_denial",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExecutionHold {
ExternalPrerequisiteClaim(ExternalBlockerClaim),
ExecutionStopped {
reason: ExecutionStopReason,
detail: String,
},
BareCompatibilityVerdict { token: String, detail: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClaimRejection {
MissingCategory,
UnsupportedCategory(String),
EmptyEvidence,
MissingUnblockCondition,
MissingNextAction,
}
impl ClaimRejection {
pub fn reason(&self) -> String {
match self {
Self::MissingCategory => "external blocker claim has no explicit category".to_string(),
Self::UnsupportedCategory(category) => format!(
"external blocker category '{category}' is not one of: {}",
SUPPORTED_BLOCKER_CATEGORIES.join(", ")
),
Self::EmptyEvidence => {
"external blocker claim has no concrete evidence entries".to_string()
}
Self::MissingUnblockCondition => {
"external blocker claim has no verifiable unblock condition".to_string()
}
Self::MissingNextAction => "external blocker claim has no next action".to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LifecycleClassification {
ExternalBlocked(ExternalBlockerInfo),
Stalled { reason: String, detail: String },
ProtocolCorrection { reason: String },
}
impl LifecycleClassification {
pub fn display_status(&self) -> Option<&'static str> {
match self {
Self::ExternalBlocked(_) => Some("blocked"),
Self::Stalled { .. } => Some("stalled"),
Self::ProtocolCorrection { .. } => None,
}
}
}
pub fn validate_external_claim(
claim: &ExternalBlockerClaim,
) -> std::result::Result<ExternalBlockerInfo, ClaimRejection> {
let category = claim.category.trim();
if category.is_empty() {
return Err(ClaimRejection::MissingCategory);
}
if !SUPPORTED_BLOCKER_CATEGORIES.contains(&category) {
return Err(ClaimRejection::UnsupportedCategory(category.to_string()));
}
let evidence = claim
.evidence
.iter()
.map(|entry| entry.trim().to_string())
.filter(|entry| !entry.is_empty())
.collect::<Vec<_>>();
if evidence.is_empty() {
return Err(ClaimRejection::EmptyEvidence);
}
let unblock_condition = claim
.unblock_condition
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or(ClaimRejection::MissingUnblockCondition)?;
let next_action = claim.next_action.trim();
if next_action.is_empty() {
return Err(ClaimRejection::MissingNextAction);
}
Ok(ExternalBlockerInfo {
origin: claim.origin,
category: category.to_string(),
evidence,
prerequisite_owner: claim
.prerequisite_owner
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
unblock_condition: unblock_condition.to_string(),
next_action: next_action.to_string(),
resumable: claim.resumable,
})
}
pub fn classify_execution_hold(hold: &ExecutionHold) -> LifecycleClassification {
match hold {
ExecutionHold::ExternalPrerequisiteClaim(claim) => match validate_external_claim(claim) {
Ok(blocker) => LifecycleClassification::ExternalBlocked(blocker),
Err(rejection) => LifecycleClassification::ProtocolCorrection {
reason: rejection.reason(),
},
},
ExecutionHold::ExecutionStopped { reason, detail } => LifecycleClassification::Stalled {
reason: reason.as_str().to_string(),
detail: detail.clone(),
},
ExecutionHold::BareCompatibilityVerdict { token, detail } => {
LifecycleClassification::ProtocolCorrection {
reason: format!(
"compatibility verdict token '{token}' carries no structured blocker facts: \
{detail}"
),
}
}
}
}
pub fn claim_from_reported_facts(blocker: &crate::events::StalledBlocker) -> ExternalBlockerClaim {
ExternalBlockerClaim {
origin: if blocker.phase.trim().eq_ignore_ascii_case("apply") {
BlockerOrigin::Apply
} else {
BlockerOrigin::Acceptance
},
category: blocker.category.clone(),
evidence: blocker.evidence.clone(),
prerequisite_owner: blocker.prerequisite_owner.clone(),
unblock_condition: blocker.unblock_condition.clone(),
next_action: blocker.next_action.clone(),
resumable: blocker.resumable,
}
}
pub fn classify_reported_facts(blocker: &crate::events::StalledBlocker) -> LifecycleClassification {
let hold = ExecutionHold::ExternalPrerequisiteClaim(claim_from_reported_facts(blocker));
match classify_execution_hold(&hold) {
LifecycleClassification::ProtocolCorrection { reason } => {
LifecycleClassification::Stalled {
reason: format!("unvalidated_external_claim:{}", blocker.category),
detail: format!("{reason}; {}", blocker.summary()),
}
}
classified => classified,
}
}
pub fn classify_execution_stop(
reason: ExecutionStopReason,
detail: impl Into<String>,
) -> LifecycleClassification {
classify_execution_hold(&ExecutionHold::ExecutionStopped {
reason,
detail: detail.into(),
})
}
pub fn bare_compatibility_diagnostic(token: &str, detail: impl Into<String>) -> String {
match classify_execution_hold(&ExecutionHold::BareCompatibilityVerdict {
token: token.to_string(),
detail: detail.into(),
}) {
LifecycleClassification::ProtocolCorrection { reason } => reason,
other => unreachable!("a bare compatibility token must need correction, got {other:?}"),
}
}
pub fn is_permission_denial(blocker: &crate::events::StalledBlocker) -> bool {
blocker.gate == "permission_policy"
}
pub fn execution_stop_reason_for(retry_reason: &str) -> ExecutionStopReason {
match retry_reason {
"repeated_acceptance_findings"
| crate::orchestration::acceptance::REPEATED_FINDING_REASON => {
ExecutionStopReason::RepeatedFinding
}
"acceptance_cycle_limit_exhausted" => ExecutionStopReason::RetryExhausted,
crate::orchestration::acceptance::REMEDIATION_MISMATCH_REASON => {
ExecutionStopReason::NoSemanticProgress
}
_ => ExecutionStopReason::NoSemanticProgress,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn claim() -> ExternalBlockerClaim {
ExternalBlockerClaim {
origin: BlockerOrigin::Acceptance,
category: "credential".to_string(),
evidence: vec!["STAGING_API_KEY is unset".to_string()],
prerequisite_owner: Some("platform".to_string()),
unblock_condition: Some("STAGING_API_KEY is present in CI".to_string()),
next_action: "provision STAGING_API_KEY then retry acceptance".to_string(),
resumable: true,
}
}
#[test]
fn classification_table_covers_every_declared_hold() {
let repository_fixable = ExternalBlockerClaim {
category: "missing_test_coverage".to_string(),
..claim()
};
let cases: Vec<(&str, ExecutionHold, Option<&str>)> = vec![
(
"acceptance external prerequisite",
ExecutionHold::ExternalPrerequisiteClaim(claim()),
Some("blocked"),
),
(
"apply external prerequisite",
ExecutionHold::ExternalPrerequisiteClaim(ExternalBlockerClaim {
origin: BlockerOrigin::Apply,
category: "infrastructure".to_string(),
evidence: vec!["docker daemon unavailable".to_string()],
unblock_condition: Some("docker daemon accepts connections".to_string()),
..claim()
}),
Some("blocked"),
),
(
"bare legacy blocked token",
ExecutionHold::BareCompatibilityVerdict {
token: "blocked".to_string(),
detail: "no blocker object".to_string(),
},
None,
),
(
"bare gated token",
ExecutionHold::BareCompatibilityVerdict {
token: "gated".to_string(),
detail: "no blocker object".to_string(),
},
None,
),
(
"missing unblock condition",
ExecutionHold::ExternalPrerequisiteClaim(ExternalBlockerClaim {
unblock_condition: None,
..claim()
}),
None,
),
(
"missing evidence",
ExecutionHold::ExternalPrerequisiteClaim(ExternalBlockerClaim {
evidence: vec![" ".to_string()],
..claim()
}),
None,
),
(
"missing next action",
ExecutionHold::ExternalPrerequisiteClaim(ExternalBlockerClaim {
next_action: " ".to_string(),
..claim()
}),
None,
),
(
"repository-fixable claim",
ExecutionHold::ExternalPrerequisiteClaim(repository_fixable),
None,
),
(
"repeated acceptance finding",
ExecutionHold::ExecutionStopped {
reason: ExecutionStopReason::RepeatedFinding,
detail: "same finding id returned".to_string(),
},
Some("stalled"),
),
(
"no semantic progress",
ExecutionHold::ExecutionStopped {
reason: ExecutionStopReason::NoSemanticProgress,
detail: "identical fingerprint".to_string(),
},
Some("stalled"),
),
(
"retry exhaustion",
ExecutionHold::ExecutionStopped {
reason: ExecutionStopReason::RetryExhausted,
detail: "cycle limit reached".to_string(),
},
Some("stalled"),
),
(
"permission denial",
ExecutionHold::ExecutionStopped {
reason: ExecutionStopReason::PermissionDenial,
detail: "denied write to /etc".to_string(),
},
Some("stalled"),
),
];
for (label, hold, expected) in cases {
let classification = classify_execution_hold(&hold);
assert_eq!(
classification.display_status(),
expected,
"{label} classified as {classification:?}"
);
if matches!(hold, ExecutionHold::ExecutionStopped { .. }) {
assert!(
!matches!(classification, LifecycleClassification::ExternalBlocked(_)),
"{label} must not become an external blocker"
);
}
}
}
#[test]
fn validated_claim_preserves_origin_and_fields_verbatim() {
let prose_heavy = ExternalBlockerClaim {
category: "human_decision".to_string(),
evidence: vec![" needs a credential token auth decision ".to_string()],
..claim()
};
let info = validate_external_claim(&prose_heavy).unwrap();
assert_eq!(info.category, "human_decision");
assert_eq!(info.origin, BlockerOrigin::Acceptance);
assert_eq!(info.evidence, ["needs a credential token auth decision"]);
assert_eq!(info.prerequisite_owner.as_deref(), Some("platform"));
assert_eq!(info.unblock_condition, "STAGING_API_KEY is present in CI");
assert!(info.resumable);
}
#[test]
fn claim_rejections_name_the_missing_field() {
for (mutated, expected) in [
(
ExternalBlockerClaim {
category: " ".to_string(),
..claim()
},
ClaimRejection::MissingCategory,
),
(
ExternalBlockerClaim {
category: "flaky_test".to_string(),
..claim()
},
ClaimRejection::UnsupportedCategory("flaky_test".to_string()),
),
(
ExternalBlockerClaim {
evidence: Vec::new(),
..claim()
},
ClaimRejection::EmptyEvidence,
),
(
ExternalBlockerClaim {
unblock_condition: Some(" ".to_string()),
..claim()
},
ClaimRejection::MissingUnblockCondition,
),
(
ExternalBlockerClaim {
next_action: String::new(),
..claim()
},
ClaimRejection::MissingNextAction,
),
] {
let rejection = validate_external_claim(&mutated).unwrap_err();
assert_eq!(rejection, expected);
assert!(!rejection.reason().is_empty());
}
}
#[test]
fn reported_facts_without_unblock_condition_classify_as_stalled() {
let facts = crate::events::StalledBlocker {
category: "credential".to_string(),
phase: "acceptance".to_string(),
gate: "acceptance".to_string(),
error_summary: "missing key".to_string(),
evidence: vec!["STAGING_API_KEY is unset".to_string()],
unblock_condition: None,
prerequisite_owner: None,
next_action: "provision the key".to_string(),
resumable: true,
worktree_preserved: true,
};
match classify_reported_facts(&facts) {
LifecycleClassification::Stalled { reason, detail } => {
assert!(reason.starts_with("unvalidated_external_claim:"));
assert!(detail.contains("unblock condition"));
}
other => panic!("expected stalled, got {other:?}"),
}
let complete = crate::events::StalledBlocker {
unblock_condition: Some("STAGING_API_KEY is present in CI".to_string()),
prerequisite_owner: Some("platform".to_string()),
..facts
};
match classify_reported_facts(&complete) {
LifecycleClassification::ExternalBlocked(info) => {
assert_eq!(info.origin, BlockerOrigin::Acceptance);
assert_eq!(info.category, "credential");
}
other => panic!("expected external blocked, got {other:?}"),
}
}
#[test]
fn origin_is_taken_from_the_reporting_phase() {
let apply_facts = crate::events::StalledBlocker {
category: "infrastructure".to_string(),
phase: "apply".to_string(),
gate: "apply".to_string(),
error_summary: "docker unavailable".to_string(),
evidence: vec!["cannot connect to the docker daemon".to_string()],
unblock_condition: Some("the docker daemon accepts connections".to_string()),
prerequisite_owner: None,
next_action: "start docker then retry apply".to_string(),
resumable: true,
worktree_preserved: true,
};
assert_eq!(
claim_from_reported_facts(&apply_facts).origin,
BlockerOrigin::Apply
);
match classify_reported_facts(&apply_facts) {
LifecycleClassification::ExternalBlocked(info) => {
assert_eq!(info.origin, BlockerOrigin::Apply);
}
other => panic!("expected external blocked, got {other:?}"),
}
}
}