# 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