ainl-mission 0.1.0

Host-neutral mission engine: state machine, DAG, scheduler, stall, task ledger (zero armaraos-* deps)
Documentation
//! Milestone validation: scrutiny fan-out and assertion failure handling.

use ainl_contracts::{
    Assertion, AssertionId, AssertionState, DiscoveredIssue, Feature, FeatureId, FeatureStatus,
};

/// Plan emitted when a milestone completes and validation should run.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationPlan {
    pub milestone: String,
    pub scrutiny_feature_ids: Vec<FeatureId>,
    pub assertion_ids: Vec<AssertionId>,
}

/// Suggested host action after an assertion fails.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AssertionFailureAction {
    /// Increment failure count; keep assertion pending for retry.
    Retry,
    /// Block milestone completion until fixed.
    BlockMilestone,
    /// Spawn a remediation feature id (host creates graph node + FIXES edge).
    ProposeFixFeature {
        suggested_feature_id: String,
        assertion_id: AssertionId,
    },
}

/// Returns true when every feature in `milestone` is terminal (`Completed` or `Cancelled`)
/// and at least one assertion is tied to that milestone.
pub fn should_inject_validation_phase(
    milestone: &str,
    features: &[Feature],
    assertions: &[Assertion],
) -> bool {
    let milestone_features: Vec<_> = features
        .iter()
        .filter(|f| f.milestone.as_deref() == Some(milestone))
        .collect();
    if milestone_features.is_empty() {
        return false;
    }
    let all_done = milestone_features.iter().all(|f| {
        matches!(
            f.status,
            FeatureStatus::Completed | FeatureStatus::Cancelled | FeatureStatus::RolledBack
        )
    });
    if !all_done {
        return false;
    }
    assertions
        .iter()
        .any(|a| a.milestone.as_deref() == Some(milestone))
}

/// Features that should receive extra scrutiny at milestone validation (non-completed work,
/// or features that fulfill assertions in this milestone).
pub fn build_scrutiny_features(
    milestone: &str,
    features: &[Feature],
    assertions: &[Assertion],
) -> Vec<FeatureId> {
    let assertion_ids: std::collections::HashSet<&str> = assertions
        .iter()
        .filter(|a| a.milestone.as_deref() == Some(milestone))
        .map(|a| a.assertion_id.as_str())
        .collect();

    let mut out = Vec::new();
    for f in features {
        if f.milestone.as_deref() != Some(milestone) {
            continue;
        }
        let fulfills_milestone_assertion = f
            .fulfills
            .iter()
            .any(|aid| assertion_ids.contains(aid.as_str()));
        let needs_scrutiny = !matches!(f.status, FeatureStatus::Completed)
            || fulfills_milestone_assertion;
        if needs_scrutiny {
            out.push(f.feature_id.clone());
        }
    }
    out.sort_by(|a, b| a.as_str().cmp(b.as_str()));
    out.dedup();
    out
}

/// Build a validation plan for a completed milestone.
pub fn build_validation_plan(
    milestone: &str,
    features: &[Feature],
    assertions: &[Assertion],
) -> Option<ValidationPlan> {
    if !should_inject_validation_phase(milestone, features, assertions) {
        return None;
    }
    Some(ValidationPlan {
        milestone: milestone.to_string(),
        scrutiny_feature_ids: build_scrutiny_features(milestone, features, assertions),
        assertion_ids: assertions
            .iter()
            .filter(|a| a.milestone.as_deref() == Some(milestone))
            .map(|a| a.assertion_id.clone())
            .collect(),
    })
}

/// Synthetic feature ids for interactive user-testing at milestone validation.
pub fn build_user_testing_features(milestone: &str) -> Vec<FeatureId> {
    vec![FeatureId(format!("user-test-{milestone}"))]
}

/// Stable remediation feature id for a blocking handoff issue.
pub fn propose_fix_for_blocking_issue(
    issue: &DiscoveredIssue,
    source_feature_id: &FeatureId,
) -> FeatureId {
    let slug: String = issue
        .description
        .chars()
        .filter(|c| c.is_ascii_alphanumeric())
        .take(20)
        .collect();
    let suffix = if slug.is_empty() { "blocking" } else { slug.as_str() };
    FeatureId(format!("fix-{}-{}", source_feature_id.as_str(), suffix))
}

/// Scrutiny reviewer feature id derived from an implementation feature.
pub fn scrutiny_feature_id(base: &FeatureId) -> FeatureId {
    FeatureId(format!("scrutiny-{}", base.as_str()))
}

/// Update assertion state after a failed check; returns recommended host action.
pub fn on_assertion_failed(
    assertion: &Assertion,
    auto_remediation_enabled: bool,
) -> (AssertionState, AssertionFailureAction) {
    let failed_count = assertion.failed_count.saturating_add(1);
    let action = if auto_remediation_enabled && failed_count >= 2 {
        AssertionFailureAction::ProposeFixFeature {
            suggested_feature_id: format!("fix-{}", assertion.assertion_id.as_str()),
            assertion_id: assertion.assertion_id.clone(),
        }
    } else if failed_count >= 3 {
        AssertionFailureAction::BlockMilestone
    } else {
        AssertionFailureAction::Retry
    };
    (AssertionState::Failed, action)
}

#[cfg(test)]
mod tests {
    use super::*;
    use ainl_contracts::{DiscoveredIssue, IssueSeverity};
    use ainl_contracts::VerificationStep;

    fn feat(id: &str, milestone: &str, status: FeatureStatus) -> Feature {
        Feature {
            feature_id: FeatureId(id.into()),
            description: id.into(),
            status,
            milestone: Some(milestone.into()),
            skill_name: None,
            touches_files: vec![],
            preconditions: vec![],
            expected_behavior: vec![],
            verification_steps: vec![],
            fulfills: vec![],
            snapshot: None,
        }
    }

    fn assertn(id: &str, milestone: &str) -> Assertion {
        Assertion {
            assertion_id: AssertionId(id.into()),
            description: id.into(),
            verification_steps: vec![VerificationStep::ShellCommand {
                cmd: "true".into(),
                expected_exit_code: 0,
            }],
            state: AssertionState::Pending,
            milestone: Some(milestone.into()),
            failed_count: 0,
        }
    }

    #[test]
    fn inject_when_milestone_complete() {
        let features = vec![
            feat("f1", "m1", FeatureStatus::Completed),
            feat("f2", "m1", FeatureStatus::Completed),
        ];
        let assertions = vec![assertn("a1", "m1")];
        assert!(should_inject_validation_phase("m1", &features, &assertions));
    }

    #[test]
    fn no_inject_while_feature_in_progress() {
        let features = vec![
            feat("f1", "m1", FeatureStatus::Completed),
            feat("f2", "m1", FeatureStatus::InProgress),
        ];
        let assertions = vec![assertn("a1", "m1")];
        assert!(!should_inject_validation_phase("m1", &features, &assertions));
    }

    #[test]
    fn assertion_failed_proposes_fix_after_two() {
        let a = Assertion {
            failed_count: 1,
            ..assertn("auth", "m1")
        };
        let (_, action) = on_assertion_failed(&a, true);
        assert!(matches!(
            action,
            AssertionFailureAction::ProposeFixFeature { .. }
        ));
    }

    #[test]
    fn user_testing_and_blocking_fix_ids() {
        let ids = build_user_testing_features("m1");
        assert_eq!(ids[0].as_str(), "user-test-m1");
        let issue = DiscoveredIssue {
            severity: IssueSeverity::Blocking,
            description: "Auth flow broken".into(),
            suggested_fix: None,
        };
        let fid = propose_fix_for_blocking_issue(&issue, &FeatureId("login".into()));
        assert!(fid.as_str().starts_with("fix-login-"));
    }
}