agileplus-trace-validator 0.3.0

Typed intent graphs for engineering work — convert prompts into validated DAGs of intents, features, tasks, tests, and edges, then store, query, and serve them from Rust, HTTP, or MCP.
Documentation
//! Validators for the three extended relationship types
//! ([`DecomposesInto`](ExtendedRelationshipType::DecomposesInto),
//! [`Mitigates`](ExtendedRelationshipType::Mitigates), and
//! [`Validates`](ExtendedRelationshipType::Validates)).
//!
//! # Why a parallel validator
//!
//! The core [`validate_edge_constraints`] function in `edges.rs` only knows
//! about the eight built-in `RelationshipType` variants. The three new
//! relationship types are serialized using a sentinel `RelationshipType`
//! (e.g. `TracesTo`, `DependsOn`, `Covers`) plus a property marker
//! `properties["relationship_type_extended"]` whose value is the original
//! extended variant's kebab-case name. To enforce the source/target
//! constraints documented for the extended ontology, callers must invoke
//! [`validate_extended_edge_constraints`] **in addition to** the existing
//! [`validate_edge_constraints`].
//!
//! Recommended usage:
//!
//! ```ignore
//! let mut errors = Vec::new();
//! errors.extend(validate_edge_constraints(&graph).err().unwrap_or_default());
//! errors.extend(validate_extended_edge_constraints(&graph).err().unwrap_or_default());
//! ```
//!
//! [`validate_edge_constraints`]: crate::validate_edge_constraints

use std::collections::HashMap;

use crate::{ValidationError, ValidationResult};
use agileplus_domain::intent_graph::{
    Edge, ExtendedRelationshipType, IntentGraph, NodeType,
};

/// Source-type allow-list per extended relationship.
const DECOMPOSES_INTO_SOURCES: &[NodeType] =
    &[NodeType::Intent, NodeType::Story, NodeType::Feature];

/// Target-type allow-list per extended relationship.
const DECOMPOSES_INTO_TARGETS: &[NodeType] =
    &[NodeType::Feature, NodeType::Task, NodeType::Story, NodeType::Bug];

/// Source (mitigator) types.
const MITIGATES_SOURCES: &[NodeType] = &[NodeType::Bug, NodeType::Intent];

/// Target (risk being mitigated) types.
const MITIGATES_TARGETS: &[NodeType] = &[NodeType::Bug, NodeType::Intent];

/// Source (validator) types. `Metric` is encoded as `Artifact` in the core
/// `NodeType` enum, so `Artifact` is included.
const VALIDATES_SOURCES: &[NodeType] = &[NodeType::Test, NodeType::Artifact];

/// Target (thing being validated) types. `Hypothesis` is encoded as `Intent`
/// and `Metric` as `Artifact`, both of which appear here.
const VALIDATES_TARGETS: &[NodeType] =
    &[NodeType::Feature, NodeType::Intent, NodeType::Story, NodeType::Artifact];

/// Property key used to recover the extended relationship variant. The
/// constructor in `ontology_ext.rs` writes this into every extended edge.
pub const EXTENDED_RELATIONSHIP_PROPERTY: &str = "relationship_type_extended";

/// Extract the extended relationship variant from an edge's `properties` map,
/// or `None` if the edge is not an extended one.
pub fn extended_relationship_of(edge: &Edge) -> Option<ExtendedRelationshipType> {
    let prop = edge.properties.as_ref()?.get(EXTENDED_RELATIONSHIP_PROPERTY)?;
    let s = prop.as_str()?;
    s.parse().ok()
}

/// Decide whether a `(source, target)` pair is valid for the given
/// extended relationship variant. Returns `Ok(())` on success or a
/// human-readable error describing the constraint.
pub fn validate_extended_edge(
    variant: ExtendedRelationshipType,
    source: NodeType,
    target: NodeType,
) -> Result<(), String> {
    let (allowed_sources, allowed_targets, label) = match variant {
        ExtendedRelationshipType::DecomposesInto => (
            DECOMPOSES_INTO_SOURCES,
            DECOMPOSES_INTO_TARGETS,
            "DecomposesInto: source must be Intent|Story|Feature; target must be Feature|Task|Story|Bug",
        ),
        ExtendedRelationshipType::Mitigates => (
            MITIGATES_SOURCES,
            MITIGATES_TARGETS,
            "Mitigates: source must be Bug|Intent; target must be Bug|Intent",
        ),
        ExtendedRelationshipType::Validates => (
            VALIDATES_SOURCES,
            VALIDATES_TARGETS,
            "Validates: source must be Test|Artifact (Metric); target must be Feature|Intent|Story|Artifact (Hypothesis|Metric)",
        ),
    };

    if !allowed_sources.contains(&source) {
        return Err(format!(
            "{label} (source {source} not in {:?})",
            allowed_sources
        ));
    }
    if !allowed_targets.contains(&target) {
        return Err(format!(
            "{label} (target {target} not in {:?})",
            allowed_targets
        ));
    }
    Ok(())
}

/// Walk every edge in `graph`, recovering the extended variant from the
/// `relationship_type_extended` property, and enforce the source/target
/// constraints. Also performs the same prerequisite checks
/// (`UnknownNode` and `SelfLoop`) the core validator does, but only for
/// extended edges — the core validator continues to handle the rest.
///
/// Callers should call **both** [`crate::validate_edge_constraints`] and
/// this function and concatenate the resulting error lists.
pub fn validate_extended_edge_constraints(graph: &IntentGraph) -> ValidationResult {
    let mut errors = Vec::new();

    // Build source/target lookup for all nodes in the graph.
    let node_types: HashMap<&agileplus_domain::intent_graph::NodeId, NodeType> =
        graph.nodes.iter().map(|n| (&n.id, n.node_type)).collect();

    for edge in &graph.edges {
        // Skip non-extended edges: their constraints are owned by the core
        // validator. We can detect them by absence of the property marker.
        let Some(variant) = extended_relationship_of(edge) else {
            continue;
        };

        // Prerequisite: source node must exist.
        let source_type = match node_types.get(&edge.source) {
            Some(t) => *t,
            None => {
                errors.push(ValidationError::UnknownNode(format!(
                    "Extended edge {} references unknown source node: {}",
                    edge.id, edge.source
                )));
                continue;
            }
        };

        // Prerequisite: target node must exist.
        let target_type = match node_types.get(&edge.target) {
            Some(t) => *t,
            None => {
                errors.push(ValidationError::UnknownNode(format!(
                    "Extended edge {} references unknown target node: {}",
                    edge.id, edge.target
                )));
                continue;
            }
        };

        // Prerequisite: no self-loops.
        if edge.source == edge.target {
            errors.push(ValidationError::SelfLoop(format!(
                "Extended edge {} is a self-loop on {}",
                edge.id, edge.source
            )));
            continue;
        }

        // Constraint: source/target types are allowed for this variant.
        if let Err(msg) = validate_extended_edge(variant, source_type, target_type) {
            errors.push(ValidationError::EdgeConstraintViolation(format!(
                "Extended edge {} ({variant:?}) violates constraint: {msg}",
                edge.id
            )));
        }
    }

    if errors.is_empty() {
        Ok(())
    } else {
        Err(errors)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use agileplus_domain::intent_graph::{Edge, NodeId};

    fn edge(
        source: &str,
        target: &str,
        variant: ExtendedRelationshipType,
    ) -> Edge {
        let json = serde_json::json!({
            EXTENDED_RELATIONSHIP_PROPERTY: variant.as_kebab(),
        });
        Edge {
            id: format!("edge-{}", source),
            source: NodeId(source.to_string()),
            target: NodeId(target.to_string()),
            relationship_type: variant.as_core_relationship_type(),
            canonical_map: None,
            meta: agileplus_domain::intent_graph::Meta {
                source: "test".to_string(),
                timestamp: "1970-01-01T00:00:00Z".to_string(),
                confidence: None,
                agent_id: None,
            },
            properties: Some(json),
            created_at: None,
        }
    }

    #[test]
    fn decomposes_into_accepts_intent_to_feature() {
        assert!(validate_extended_edge(
            ExtendedRelationshipType::DecomposesInto,
            NodeType::Intent,
            NodeType::Feature
        )
        .is_ok());
    }

    #[test]
    fn decomposes_into_rejects_task_source() {
        let err = validate_extended_edge(
            ExtendedRelationshipType::DecomposesInto,
            NodeType::Task,
            NodeType::Task,
        )
        .unwrap_err();
        assert!(err.contains("DecomposesInto"));
        assert!(err.contains("source"));
    }

    #[test]
    fn mitigates_accepts_bug_to_intent() {
        assert!(validate_extended_edge(
            ExtendedRelationshipType::Mitigates,
            NodeType::Bug,
            NodeType::Intent
        )
        .is_ok());
    }

    #[test]
    fn mitigates_rejects_feature_source() {
        let err = validate_extended_edge(
            ExtendedRelationshipType::Mitigates,
            NodeType::Feature,
            NodeType::Bug,
        )
        .unwrap_err();
        assert!(err.contains("Mitigates"));
    }

    #[test]
    fn validates_accepts_test_to_feature() {
        assert!(validate_extended_edge(
            ExtendedRelationshipType::Validates,
            NodeType::Test,
            NodeType::Feature,
        )
        .is_ok());
    }

    #[test]
    fn validates_accepts_artifact_metric_to_intent_hypothesis() {
        // Metric → Artifact, Hypothesis → Intent
        assert!(validate_extended_edge(
            ExtendedRelationshipType::Validates,
            NodeType::Artifact,
            NodeType::Intent,
        )
        .is_ok());
    }

    #[test]
    fn validates_rejects_bug_source() {
        let err = validate_extended_edge(
            ExtendedRelationshipType::Validates,
            NodeType::Bug,
            NodeType::Feature,
        )
        .unwrap_err();
        assert!(err.contains("Validates"));
    }

    #[test]
    fn extended_relationship_of_recovers_variant() {
        let e = edge("Story#a", "Task#b", ExtendedRelationshipType::DecomposesInto);
        assert_eq!(
            extended_relationship_of(&e),
            Some(ExtendedRelationshipType::DecomposesInto)
        );
    }

    #[test]
    fn extended_relationship_of_none_for_plain_edge() {
        let mut e = edge("Story#a", "Task#b", ExtendedRelationshipType::Mitigates);
        e.properties = None;
        assert_eq!(extended_relationship_of(&e), None);
    }
}