- 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.
31 KiB
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.mddocs/product-advisories/16-Dec-2025 - Reimagining Proof‑Linked 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.csfile 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.csfile 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:
WouldPassIffield added toPolicyExplanation- 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 buildsucceedsdotnet testsucceeds