# 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 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**:
```csharp
namespace StellaOps.Policy.Counterfactuals;
///
/// Result of counterfactual analysis - what would flip the verdict.
///
public sealed record CounterfactualResult
{
///
/// The finding this analysis applies to.
///
public required Guid FindingId { get; init; }
///
/// Current verdict for this finding.
///
public required string CurrentVerdict { get; init; }
///
/// What the verdict would change to.
///
public required string TargetVerdict { get; init; }
///
/// Possible paths to flip the verdict.
///
public required IReadOnlyList Paths { get; init; }
///
/// Whether any path exists.
///
public bool HasPaths => Paths.Count > 0;
///
/// The recommended path (lowest effort).
///
public CounterfactualPath? RecommendedPath =>
Paths.OrderBy(p => p.EstimatedEffort).FirstOrDefault();
}
///
/// A single path that would flip the verdict.
///
public sealed record CounterfactualPath
{
///
/// Type of change required.
///
public required CounterfactualType Type { get; init; }
///
/// Human-readable description of what would need to change.
///
public required string Description { get; init; }
///
/// Specific conditions that would need to be met.
///
public required IReadOnlyList Conditions { get; init; }
///
/// Estimated effort level (1-5).
///
public int EstimatedEffort { get; init; }
///
/// Who can take this action.
///
public required string Actor { get; init; }
///
/// Link to relevant documentation or action.
///
public string? ActionUri { get; init; }
}
///
/// A specific condition in a counterfactual path.
///
public sealed record CounterfactualCondition
{
///
/// What needs to change.
///
public required string Field { get; init; }
///
/// Current value.
///
public required string CurrentValue { get; init; }
///
/// Required value.
///
public required string RequiredValue { get; init; }
///
/// Whether this condition is currently met.
///
public bool IsMet { get; init; }
}
///
/// Type of counterfactual change.
///
public enum CounterfactualType
{
/// VEX status would need to change.
VexStatus,
/// An exception would need to be granted.
Exception,
/// Reachability status would need to change.
Reachability,
/// Component version would need to change.
VersionUpgrade,
/// Policy rule would need to be modified.
PolicyChange,
/// Component would need to be removed.
ComponentRemoval,
/// Compensating control would need to be applied.
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**:
```csharp
namespace StellaOps.Policy.Counterfactuals;
///
/// Engine for computing policy counterfactuals.
///
public interface ICounterfactualEngine
{
Task ComputeAsync(
PolicyEvaluationContext context,
Guid findingId,
CancellationToken ct = default);
}
///
/// Default implementation of counterfactual engine.
///
public sealed class CounterfactualEngine : ICounterfactualEngine
{
private readonly IPolicyEvaluator _evaluator;
private readonly ILogger _logger;
public CounterfactualEngine(
IPolicyEvaluator evaluator,
ILogger logger)
{
_evaluator = evaluator;
_logger = logger;
}
public async Task 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();
// 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 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 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 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 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**:
```csharp
// Add to PolicyExplanation.cs
public sealed record PolicyExplanation
{
// ... existing fields ...
///
/// Counterfactual paths showing what would flip the verdict.
///
public CounterfactualResult? WouldPassIf { get; init; }
}
// Modify PolicyExplanationBuilder or PolicyEvaluator
public async Task 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:
```csharp
private async Task 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**:
```csharp
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**:
```csharp
private async Task 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**:
```csharp
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();
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**:
```csharp
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