Files
git.stella-ops.org/docs/implplan/SPRINT_4200_0002_0005_counterfactuals.md
master 53503cb407 Add reference architecture and testing strategy documentation
- Created a new document for the Stella Ops Reference Architecture outlining the system's topology, trust boundaries, artifact association, and interfaces.
- Developed a comprehensive Testing Strategy document detailing the importance of offline readiness, interoperability, determinism, and operational guardrails.
- Introduced a README for the Testing Strategy, summarizing processing details and key concepts implemented.
- Added guidance for AI agents and developers in the tests directory, including directory structure, test categories, key patterns, and rules for test development.
2025-12-22 07:59:30 +02:00

31 KiB
Raw Blame History

Sprint 4200.0002.0005 · Policy Counterfactuals

Topic & Scope

  • Compute minimal changes needed to make a blocked finding pass
  • Show "what would flip the verdict" for VEX, exceptions, and reachability
  • Provide actionable guidance for remediation

Working directory: src/Policy/__Libraries/StellaOps.Policy/

Dependencies & Concurrency

  • Upstream: None (can start immediately)
  • Downstream: None
  • Safe to parallelize with: All other UX sprints

Documentation Prerequisites

  • src/Policy/__Libraries/StellaOps.Policy/AGENTS.md
  • docs/product-advisories/16-Dec-2025 - Reimagining ProofLinked UX in Security Workflows.md
  • Existing: PolicyExplanation, PolicyEvaluator

Problem Statement

PolicyExplanation currently shows "why blocked" but not "what would make it pass". This creates friction - users see a block but don't know the minimal path to resolution.


Tasks

T1: Define CounterfactualResult

Assignee: Policy Team Story Points: 2 Status: TODO Dependencies: —

Description: Create model for counterfactual analysis results.

Implementation Path: Counterfactuals/CounterfactualResult.cs (new file)

Implementation:

namespace StellaOps.Policy.Counterfactuals;

/// <summary>
/// Result of counterfactual analysis - what would flip the verdict.
/// </summary>
public sealed record CounterfactualResult
{
    /// <summary>
    /// The finding this analysis applies to.
    /// </summary>
    public required Guid FindingId { get; init; }

    /// <summary>
    /// Current verdict for this finding.
    /// </summary>
    public required string CurrentVerdict { get; init; }

    /// <summary>
    /// What the verdict would change to.
    /// </summary>
    public required string TargetVerdict { get; init; }

    /// <summary>
    /// Possible paths to flip the verdict.
    /// </summary>
    public required IReadOnlyList<CounterfactualPath> Paths { get; init; }

    /// <summary>
    /// Whether any path exists.
    /// </summary>
    public bool HasPaths => Paths.Count > 0;

    /// <summary>
    /// The recommended path (lowest effort).
    /// </summary>
    public CounterfactualPath? RecommendedPath =>
        Paths.OrderBy(p => p.EstimatedEffort).FirstOrDefault();
}

/// <summary>
/// A single path that would flip the verdict.
/// </summary>
public sealed record CounterfactualPath
{
    /// <summary>
    /// Type of change required.
    /// </summary>
    public required CounterfactualType Type { get; init; }

    /// <summary>
    /// Human-readable description of what would need to change.
    /// </summary>
    public required string Description { get; init; }

    /// <summary>
    /// Specific conditions that would need to be met.
    /// </summary>
    public required IReadOnlyList<CounterfactualCondition> Conditions { get; init; }

    /// <summary>
    /// Estimated effort level (1-5).
    /// </summary>
    public int EstimatedEffort { get; init; }

    /// <summary>
    /// Who can take this action.
    /// </summary>
    public required string Actor { get; init; }

    /// <summary>
    /// Link to relevant documentation or action.
    /// </summary>
    public string? ActionUri { get; init; }
}

/// <summary>
/// A specific condition in a counterfactual path.
/// </summary>
public sealed record CounterfactualCondition
{
    /// <summary>
    /// What needs to change.
    /// </summary>
    public required string Field { get; init; }

    /// <summary>
    /// Current value.
    /// </summary>
    public required string CurrentValue { get; init; }

    /// <summary>
    /// Required value.
    /// </summary>
    public required string RequiredValue { get; init; }

    /// <summary>
    /// Whether this condition is currently met.
    /// </summary>
    public bool IsMet { get; init; }
}

/// <summary>
/// Type of counterfactual change.
/// </summary>
public enum CounterfactualType
{
    /// <summary>VEX status would need to change.</summary>
    VexStatus,

    /// <summary>An exception would need to be granted.</summary>
    Exception,

    /// <summary>Reachability status would need to change.</summary>
    Reachability,

    /// <summary>Component version would need to change.</summary>
    VersionUpgrade,

    /// <summary>Policy rule would need to be modified.</summary>
    PolicyChange,

    /// <summary>Component would need to be removed.</summary>
    ComponentRemoval,

    /// <summary>Compensating control would need to be applied.</summary>
    CompensatingControl
}

Acceptance Criteria:

  • CounterfactualResult.cs file created
  • Models for result, path, and condition
  • CounterfactualType enum defined
  • Estimated effort field
  • Actor field for who can take action

T2: Create CounterfactualEngine

Assignee: Policy Team Story Points: 3 Status: TODO Dependencies: T1

Description: Implement engine to compute minimal changes needed.

Implementation Path: Counterfactuals/CounterfactualEngine.cs (new file)

Implementation:

namespace StellaOps.Policy.Counterfactuals;

/// <summary>
/// Engine for computing policy counterfactuals.
/// </summary>
public interface ICounterfactualEngine
{
    Task<CounterfactualResult> ComputeAsync(
        PolicyEvaluationContext context,
        Guid findingId,
        CancellationToken ct = default);
}

/// <summary>
/// Default implementation of counterfactual engine.
/// </summary>
public sealed class CounterfactualEngine : ICounterfactualEngine
{
    private readonly IPolicyEvaluator _evaluator;
    private readonly ILogger<CounterfactualEngine> _logger;

    public CounterfactualEngine(
        IPolicyEvaluator evaluator,
        ILogger<CounterfactualEngine> logger)
    {
        _evaluator = evaluator;
        _logger = logger;
    }

    public async Task<CounterfactualResult> ComputeAsync(
        PolicyEvaluationContext context,
        Guid findingId,
        CancellationToken ct = default)
    {
        var finding = context.GetFinding(findingId);
        if (finding is null)
        {
            throw new ArgumentException($"Finding {findingId} not found in context");
        }

        var currentEval = await _evaluator.EvaluateFindingAsync(context, finding, ct);
        if (currentEval.Decision == PolicyDecision.Allow)
        {
            // Already passing - no counterfactuals needed
            return new CounterfactualResult
            {
                FindingId = findingId,
                CurrentVerdict = "Ship",
                TargetVerdict = "Ship",
                Paths = []
            };
        }

        var paths = new List<CounterfactualPath>();

        // Check VEX counterfactual
        var vexPath = await ComputeVexCounterfactualAsync(context, finding, ct);
        if (vexPath is not null) paths.Add(vexPath);

        // Check exception counterfactual
        var exceptionPath = ComputeExceptionCounterfactual(context, finding);
        if (exceptionPath is not null) paths.Add(exceptionPath);

        // Check reachability counterfactual
        var reachPath = await ComputeReachabilityCounterfactualAsync(context, finding, ct);
        if (reachPath is not null) paths.Add(reachPath);

        // Check version upgrade counterfactual
        var versionPath = await ComputeVersionUpgradeCounterfactualAsync(context, finding, ct);
        if (versionPath is not null) paths.Add(versionPath);

        // Check compensating control counterfactual
        var compensatingPath = ComputeCompensatingControlCounterfactual(context, finding);
        if (compensatingPath is not null) paths.Add(compensatingPath);

        return new CounterfactualResult
        {
            FindingId = findingId,
            CurrentVerdict = currentEval.Decision == PolicyDecision.Deny ? "Block" : "Exception",
            TargetVerdict = "Ship",
            Paths = paths.OrderBy(p => p.EstimatedEffort).ToList()
        };
    }

    private async Task<CounterfactualPath?> ComputeVexCounterfactualAsync(
        PolicyEvaluationContext context,
        Finding finding,
        CancellationToken ct)
    {
        // Only applicable if current VEX status is Affected or UnderInvestigation
        if (finding.VexStatus == VexStatus.NotAffected)
            return null;

        // Simulate with NotAffected status
        var modifiedContext = context.WithModifiedFinding(finding with
        {
            VexStatus = VexStatus.NotAffected
        });

        var simResult = await _evaluator.EvaluateFindingAsync(modifiedContext, finding, ct);
        if (simResult.Decision != PolicyDecision.Allow)
            return null;

        return new CounterfactualPath
        {
            Type = CounterfactualType.VexStatus,
            Description = "Would pass if VEX status is 'not_affected'",
            Conditions =
            [
                new CounterfactualCondition
                {
                    Field = "VEX Status",
                    CurrentValue = finding.VexStatus.ToString(),
                    RequiredValue = "NotAffected",
                    IsMet = false
                }
            ],
            EstimatedEffort = 2,
            Actor = "Vendor or Security Team",
            ActionUri = "/vex/create"
        };
    }

    private CounterfactualPath? ComputeExceptionCounterfactual(
        PolicyEvaluationContext context,
        Finding finding)
    {
        // Check if an exception is allowed by policy
        if (!context.Policy.AllowsExceptions)
            return null;

        return new CounterfactualPath
        {
            Type = CounterfactualType.Exception,
            Description = $"Would pass with a security exception for {finding.VulnId}",
            Conditions =
            [
                new CounterfactualCondition
                {
                    Field = "Exception",
                    CurrentValue = "None",
                    RequiredValue = "Approved exception covering this CVE",
                    IsMet = false
                }
            ],
            EstimatedEffort = 3,
            Actor = "Security Team or Exception Approver",
            ActionUri = $"/exceptions/request?cve={finding.VulnId}"
        };
    }

    private async Task<CounterfactualPath?> ComputeReachabilityCounterfactualAsync(
        PolicyEvaluationContext context,
        Finding finding,
        CancellationToken ct)
    {
        // Only if reachability affects this decision
        if (finding.Reachability == Reachability.No)
            return null;

        if (!context.Policy.ConsidersReachability)
            return null;

        // Simulate with not reachable
        var modifiedContext = context.WithModifiedFinding(finding with
        {
            Reachability = Reachability.No
        });

        var simResult = await _evaluator.EvaluateFindingAsync(modifiedContext, finding, ct);
        if (simResult.Decision != PolicyDecision.Allow)
            return null;

        return new CounterfactualPath
        {
            Type = CounterfactualType.Reachability,
            Description = "Would pass if vulnerable code is not reachable",
            Conditions =
            [
                new CounterfactualCondition
                {
                    Field = "Reachability",
                    CurrentValue = finding.Reachability.ToString(),
                    RequiredValue = "No (not reachable)",
                    IsMet = false
                }
            ],
            EstimatedEffort = 4,
            Actor = "Development Team",
            ActionUri = $"/reachability/analyze?finding={finding.Id}"
        };
    }

    private async Task<CounterfactualPath?> ComputeVersionUpgradeCounterfactualAsync(
        PolicyEvaluationContext context,
        Finding finding,
        CancellationToken ct)
    {
        // Check if there's a fixed version available
        var fixedVersion = await GetFixedVersionAsync(finding.VulnId, finding.Purl, ct);
        if (fixedVersion is null)
            return null;

        return new CounterfactualPath
        {
            Type = CounterfactualType.VersionUpgrade,
            Description = $"Would pass by upgrading to {fixedVersion}",
            Conditions =
            [
                new CounterfactualCondition
                {
                    Field = "Version",
                    CurrentValue = finding.Version,
                    RequiredValue = fixedVersion,
                    IsMet = false
                }
            ],
            EstimatedEffort = 2,
            Actor = "Development Team",
            ActionUri = $"/components/{Uri.EscapeDataString(finding.Purl)}/upgrade"
        };
    }

    private CounterfactualPath? ComputeCompensatingControlCounterfactual(
        PolicyEvaluationContext context,
        Finding finding)
    {
        // Only if compensating controls are supported
        if (!context.Policy.AllowsCompensatingControls)
            return null;

        return new CounterfactualPath
        {
            Type = CounterfactualType.CompensatingControl,
            Description = "Would pass with documented compensating control",
            Conditions =
            [
                new CounterfactualCondition
                {
                    Field = "Compensating Control",
                    CurrentValue = "None",
                    RequiredValue = "Approved control mitigating the risk",
                    IsMet = false
                }
            ],
            EstimatedEffort = 4,
            Actor = "Security Team",
            ActionUri = $"/controls/create?finding={finding.Id}"
        };
    }

    private async Task<string?> GetFixedVersionAsync(
        string vulnId, string purl, CancellationToken ct)
    {
        // Query advisory database for fixed version
        // Implementation depends on advisory service
        return null; // Placeholder
    }
}

Acceptance Criteria:

  • CounterfactualEngine.cs file created
  • Computes VEX counterfactual
  • Computes exception counterfactual
  • Computes reachability counterfactual
  • Computes version upgrade counterfactual
  • Computes compensating control counterfactual
  • Orders by estimated effort

T3: Integrate with PolicyExplanation

Assignee: Policy Team Story Points: 2 Status: TODO Dependencies: T2

Description: Add WouldPassIf field to PolicyExplanation.

Implementation Path: Modify PolicyExplanation.cs

Implementation:

// Add to PolicyExplanation.cs
public sealed record PolicyExplanation
{
    // ... existing fields ...

    /// <summary>
    /// Counterfactual paths showing what would flip the verdict.
    /// </summary>
    public CounterfactualResult? WouldPassIf { get; init; }
}

// Modify PolicyExplanationBuilder or PolicyEvaluator
public async Task<PolicyExplanation> BuildExplanationAsync(
    PolicyEvaluationContext context,
    Finding finding,
    bool includeCounterfactuals = true,
    CancellationToken ct = default)
{
    var explanation = new PolicyExplanation
    {
        Decision = evaluation.Decision,
        Reason = evaluation.Reason,
        MatchedRules = evaluation.MatchedRules,
        // ... other fields ...
    };

    if (includeCounterfactuals && evaluation.Decision != PolicyDecision.Allow)
    {
        var counterfactuals = await _counterfactualEngine.ComputeAsync(
            context, finding.Id, ct);
        explanation = explanation with { WouldPassIf = counterfactuals };
    }

    return explanation;
}

Acceptance Criteria:

  • WouldPassIf field added to PolicyExplanation
  • Populated when decision is not Allow
  • Optional flag to skip computation
  • Does not break existing API

T4: Handle VEX Counterfactuals

Assignee: Policy Team Story Points: 2 Status: TODO Dependencies: T2

Description: Detailed VEX counterfactual handling.

Implementation: Extended in T2, add helper methods:

private async Task<CounterfactualPath?> ComputeDetailedVexCounterfactualAsync(
    PolicyEvaluationContext context,
    Finding finding,
    CancellationToken ct)
{
    // Check existing VEX statements
    var existingVex = await _vexService.GetStatementsAsync(
        finding.VulnId, finding.Purl, ct);

    if (existingVex.Any(v => v.Status == VexStatus.NotAffected && v.Source == "vendor"))
    {
        // Vendor already says not affected - policy might be ignoring it
        return new CounterfactualPath
        {
            Type = CounterfactualType.VexStatus,
            Description = "Vendor VEX exists but is not trusted. Review trust policy.",
            Conditions =
            [
                new CounterfactualCondition
                {
                    Field = "VEX Trust",
                    CurrentValue = "Untrusted",
                    RequiredValue = "Trusted vendor VEX",
                    IsMet = false
                }
            ],
            EstimatedEffort = 1,
            Actor = "Security Team",
            ActionUri = "/settings/vex-trust"
        };
    }

    // Standard VEX counterfactual
    return new CounterfactualPath
    {
        Type = CounterfactualType.VexStatus,
        Description = $"Would pass if vendor publishes VEX with status 'not_affected' for {finding.VulnId}",
        Conditions =
        [
            new CounterfactualCondition
            {
                Field = "VEX Status",
                CurrentValue = finding.VexStatus.ToString(),
                RequiredValue = "NotAffected (from trusted source)",
                IsMet = false
            }
        ],
        EstimatedEffort = 3,
        Actor = "Vendor",
        ActionUri = $"https://github.com/{ExtractRepo(finding.Purl)}/security/advisories"
    };
}

Acceptance Criteria:

  • Detects existing untrusted VEX
  • Suggests trust policy review
  • Links to vendor advisory creation
  • Handles different VEX sources

T5: Handle Exception Counterfactuals

Assignee: Policy Team Story Points: 2 Status: TODO Dependencies: T2

Description: Detailed exception counterfactual handling.

Implementation:

private CounterfactualPath? ComputeDetailedExceptionCounterfactual(
    PolicyEvaluationContext context,
    Finding finding)
{
    // Check exception eligibility
    var eligibility = CheckExceptionEligibility(context.Policy, finding);

    if (!eligibility.IsEligible)
    {
        return new CounterfactualPath
        {
            Type = CounterfactualType.Exception,
            Description = $"Exception not allowed: {eligibility.Reason}",
            Conditions =
            [
                new CounterfactualCondition
                {
                    Field = "Exception Policy",
                    CurrentValue = eligibility.Reason,
                    RequiredValue = "Policy allows exceptions for this severity",
                    IsMet = false
                }
            ],
            EstimatedEffort = 5,
            Actor = "Policy Admin",
            ActionUri = "/policy/edit"
        };
    }

    // Check if there's a pending exception request
    var pendingRequest = context.PendingExceptions
        .FirstOrDefault(e => e.CoverId == finding.VulnId);

    if (pendingRequest is not null)
    {
        return new CounterfactualPath
        {
            Type = CounterfactualType.Exception,
            Description = $"Exception request pending approval (ID: {pendingRequest.Id})",
            Conditions =
            [
                new CounterfactualCondition
                {
                    Field = "Exception Status",
                    CurrentValue = "Pending",
                    RequiredValue = "Approved",
                    IsMet = false
                }
            ],
            EstimatedEffort = 1,
            Actor = pendingRequest.ApproverRole,
            ActionUri = $"/exceptions/{pendingRequest.Id}/approve"
        };
    }

    // Standard exception path
    return new CounterfactualPath
    {
        Type = CounterfactualType.Exception,
        Description = $"Would pass with approved security exception for {finding.VulnId}",
        Conditions =
        [
            new CounterfactualCondition
            {
                Field = "Exception",
                CurrentValue = "None",
                RequiredValue = "Approved exception with risk acceptance",
                IsMet = false
            }
        ],
        EstimatedEffort = ComputeExceptionEffort(finding),
        Actor = GetExceptionApprover(context.Policy, finding),
        ActionUri = $"/exceptions/request?cve={finding.VulnId}&purl={Uri.EscapeDataString(finding.Purl)}"
    };
}

private static int ComputeExceptionEffort(Finding finding)
{
    // Higher severity = more effort to get exception
    return finding.CvssScore switch
    {
        >= 9.0m => 5,
        >= 7.0m => 4,
        >= 4.0m => 3,
        _ => 2
    };
}

Acceptance Criteria:

  • Checks exception eligibility
  • Detects pending requests
  • Links to approval workflow
  • Effort scales with severity

T6: Handle Reachability Counterfactuals

Assignee: Policy Team Story Points: 2 Status: TODO Dependencies: T2

Description: Detailed reachability counterfactual handling.

Implementation:

private async Task<CounterfactualPath?> ComputeDetailedReachabilityCounterfactualAsync(
    PolicyEvaluationContext context,
    Finding finding,
    CancellationToken ct)
{
    if (!context.Policy.ConsidersReachability)
    {
        return new CounterfactualPath
        {
            Type = CounterfactualType.PolicyChange,
            Description = "Policy does not consider reachability. Enable reachability analysis in policy.",
            Conditions =
            [
                new CounterfactualCondition
                {
                    Field = "Policy Setting",
                    CurrentValue = "Reachability disabled",
                    RequiredValue = "Reachability enabled",
                    IsMet = false
                }
            ],
            EstimatedEffort = 2,
            Actor = "Policy Admin",
            ActionUri = "/policy/edit?setting=reachability"
        };
    }

    // Check if reachability analysis was attempted
    if (finding.Reachability == Reachability.Unknown)
    {
        return new CounterfactualPath
        {
            Type = CounterfactualType.Reachability,
            Description = "Reachability unknown. Run reachability analysis to potentially mute.",
            Conditions =
            [
                new CounterfactualCondition
                {
                    Field = "Reachability Analysis",
                    CurrentValue = "Not run",
                    RequiredValue = "Complete analysis showing not reachable",
                    IsMet = false
                }
            ],
            EstimatedEffort = 2,
            Actor = "Development Team",
            ActionUri = $"/scan/{context.ScanId}/reachability/run"
        };
    }

    // Currently reachable - would need code changes
    return new CounterfactualPath
    {
        Type = CounterfactualType.Reachability,
        Description = "Vulnerable code is reachable. Remove call path to mute.",
        Conditions =
        [
            new CounterfactualCondition
            {
                Field = "Call Path",
                CurrentValue = "Reachable from entry points",
                RequiredValue = "No path from entry points",
                IsMet = false
            }
        ],
        EstimatedEffort = 4,
        Actor = "Development Team",
        ActionUri = $"/findings/{finding.Id}/callgraph"
    };
}

Acceptance Criteria:

  • Checks if policy considers reachability
  • Handles unknown reachability
  • Links to reachability analysis
  • Shows call path info

T7: API Endpoint

Assignee: Policy Team Story Points: 2 Status: TODO Dependencies: T2, T3

Description: Create API endpoint for counterfactual queries.

Implementation Path: src/Policy/StellaOps.Policy.WebService/Endpoints/CounterfactualEndpoints.cs

Implementation:

namespace StellaOps.Policy.WebService.Endpoints;

public static class CounterfactualEndpoints
{
    public static void MapCounterfactualEndpoints(this WebApplication app)
    {
        var group = app.MapGroup("/api/v1/policy/counterfactuals")
            .WithTags("Policy Counterfactuals")
            .RequireAuthorization();

        // GET /counterfactuals/{findingId}
        group.MapGet("/{findingId:guid}", async (
            Guid findingId,
            [FromQuery] Guid? evaluationId,
            ICounterfactualEngine engine,
            IPolicyContextProvider contextProvider,
            CancellationToken ct) =>
        {
            var context = evaluationId.HasValue
                ? await contextProvider.GetContextForEvaluationAsync(evaluationId.Value, ct)
                : await contextProvider.GetCurrentContextAsync(findingId, ct);

            if (context is null)
                return Results.NotFound("Context not found");

            var result = await engine.ComputeAsync(context, findingId, ct);
            return Results.Ok(result);
        })
        .WithName("GetCounterfactuals")
        .WithDescription("Get counterfactual paths for a finding");

        // GET /evaluations/{evaluationId}/counterfactuals
        group.MapGet("/evaluations/{evaluationId:guid}", async (
            Guid evaluationId,
            ICounterfactualEngine engine,
            IPolicyContextProvider contextProvider,
            CancellationToken ct) =>
        {
            var context = await contextProvider.GetContextForEvaluationAsync(evaluationId, ct);
            if (context is null)
                return Results.NotFound("Evaluation not found");

            var results = new List<CounterfactualResult>();
            foreach (var finding in context.BlockedFindings)
            {
                var result = await engine.ComputeAsync(context, finding.Id, ct);
                results.Add(result);
            }

            return Results.Ok(new { evaluationId, counterfactuals = results });
        })
        .WithName("GetEvaluationCounterfactuals")
        .WithDescription("Get counterfactuals for all blocked findings in an evaluation");
    }
}

Acceptance Criteria:

  • GET /counterfactuals/{findingId} works
  • GET /evaluations/{id}/counterfactuals works
  • Returns structured JSON
  • Handles missing contexts gracefully

T8: Tests

Assignee: Policy Team Story Points: 2 Status: TODO Dependencies: T1-T7

Description: Comprehensive tests for counterfactual scenarios.

Implementation Path: src/Policy/__Tests/StellaOps.Policy.Tests/Counterfactuals/

Test Cases:

public class CounterfactualEngineTests
{
    [Fact]
    public async Task Compute_AlreadyPassing_ReturnsEmptyPaths()
    {
        var context = CreateContext(PolicyDecision.Allow);
        var finding = CreateFinding();

        var result = await _engine.ComputeAsync(context, finding.Id);

        result.Paths.Should().BeEmpty();
        result.CurrentVerdict.Should().Be("Ship");
    }

    [Fact]
    public async Task Compute_VexWouldFlip_ReturnsVexPath()
    {
        var context = CreateContext(PolicyDecision.Deny);
        context.Policy.ConsidersVex = true;
        var finding = CreateFinding(vexStatus: VexStatus.Affected);

        var result = await _engine.ComputeAsync(context, finding.Id);

        result.Paths.Should().Contain(p => p.Type == CounterfactualType.VexStatus);
    }

    [Fact]
    public async Task Compute_ExceptionWouldFlip_ReturnsExceptionPath()
    {
        var context = CreateContext(PolicyDecision.Deny);
        context.Policy.AllowsExceptions = true;
        var finding = CreateFinding();

        var result = await _engine.ComputeAsync(context, finding.Id);

        result.Paths.Should().Contain(p => p.Type == CounterfactualType.Exception);
    }

    [Fact]
    public async Task Compute_ReachabilityWouldFlip_ReturnsReachabilityPath()
    {
        var context = CreateContext(PolicyDecision.Deny);
        context.Policy.ConsidersReachability = true;
        var finding = CreateFinding(reachability: Reachability.Yes);

        var result = await _engine.ComputeAsync(context, finding.Id);

        result.Paths.Should().Contain(p => p.Type == CounterfactualType.Reachability);
    }

    [Fact]
    public async Task Compute_MultiplePaths_OrdersByEffort()
    {
        var context = CreateContext(PolicyDecision.Deny);
        context.Policy.AllowsExceptions = true;
        context.Policy.ConsidersVex = true;
        var finding = CreateFinding();

        var result = await _engine.ComputeAsync(context, finding.Id);

        result.Paths.Should().BeInAscendingOrder(p => p.EstimatedEffort);
    }

    [Fact]
    public async Task Compute_RecommendedPath_IsLowestEffort()
    {
        var context = CreateContext(PolicyDecision.Deny);
        var finding = CreateFinding();

        var result = await _engine.ComputeAsync(context, finding.Id);

        result.RecommendedPath.Should().NotBeNull();
        result.RecommendedPath!.EstimatedEffort.Should().Be(
            result.Paths.Min(p => p.EstimatedEffort));
    }

    [Fact]
    public async Task Compute_PendingException_ShowsPendingPath()
    {
        var context = CreateContext(PolicyDecision.Deny);
        context.PendingExceptions = [new PendingException { CoveId = "CVE-2024-1234" }];
        var finding = CreateFinding(vulnId: "CVE-2024-1234");

        var result = await _engine.ComputeAsync(context, finding.Id);

        var exceptionPath = result.Paths.First(p => p.Type == CounterfactualType.Exception);
        exceptionPath.Description.Should().Contain("pending");
        exceptionPath.EstimatedEffort.Should().Be(1);
    }
}

Acceptance Criteria:

  • Test for passing findings
  • Test for VEX counterfactual
  • Test for exception counterfactual
  • Test for reachability counterfactual
  • Test for effort ordering
  • Test for recommended path
  • All tests pass

Delivery Tracker

# Task ID Status Dependency Owners Task Definition
1 T1 TODO Policy Team Define CounterfactualResult
2 T2 TODO T1 Policy Team Create CounterfactualEngine
3 T3 TODO T2 Policy Team Integrate with PolicyExplanation
4 T4 TODO T2 Policy Team Handle VEX counterfactuals
5 T5 TODO T2 Policy Team Handle exception counterfactuals
6 T6 TODO T2 Policy Team Handle reachability counterfactuals
7 T7 TODO T2, T3 Policy Team API endpoint
8 T8 TODO T1-T7 Policy Team Tests

Execution Log

Date (UTC) Update Owner
2025-12-21 Sprint created from UX Gap Analysis. Counterfactuals identified as key actionability feature. Claude

Decisions & Risks

Item Type Owner Notes
Effort scale Decision Policy Team 1-5 scale, lower is easier
Simulation approach Decision Policy Team Modify context and re-evaluate
Path ordering Decision Policy Team Order by effort ascending
Actor field Decision Policy Team Who can take the remediation action

Success Criteria

  • All 8 tasks marked DONE
  • Counterfactuals show minimal changes to pass
  • VEX, exception, reachability scenarios covered
  • API returns structured counterfactual list
  • Each counterfactual has actionable guidance
  • Integration with PolicyExplanation works
  • All tests pass
  • dotnet build succeeds
  • dotnet test succeeds