REACH-013

This commit is contained in:
StellaOps Bot
2025-12-19 22:32:38 +02:00
parent 5b57b04484
commit edc91ea96f
5 changed files with 925 additions and 36 deletions

View File

@@ -27,10 +27,10 @@
| 4 | EXCITITOR-CORE-AOC-19-002/003/004/013 | DONE (2025-12-07) | Implemented append-only linkset contracts and deprecated consensus | Excititor Core Guild | Deterministic advisory/PURL extraction, append-only linksets, remove consensus logic, seed Authority tenants in tests. |
| 5 | EXCITITOR-STORAGE-00-001 | DONE (2025-12-08) | Append-only Postgres backend delivered; Storage.Mongo references to be removed in follow-on cleanup | Excititor Core + Platform Data Guild | Select and ratify storage backend (e.g., SQL/append-only) for observations, linksets, and worker checkpoints; produce migration plan + deterministic test harnesses without Mongo. |
| 6 | EXCITITOR-GRAPH-21-001..005 | DONE (2025-12-11) | Overlay schema v1.0.0 implemented; WebService overlays/status with Postgres-backed materialization + cache | Excititor Core + UI Guild | Batched VEX fetches, overlay metadata, indexes/materialized views for graph inspector on the non-Mongo store. |
| 7 | EXCITITOR-OBS-52/53/54 | TODO | Provenance schema now aligned to overlay contract; implement evidence locker DSSE flow next | Excititor Core + Evidence Locker + Provenance Guilds | Timeline events, Merkle locker payloads, DSSE attestations for evidence batches. |
| 8 | EXCITITOR-ORCH-32/33 | TODO | Overlay schema set; wire orchestrator SDK + Postgres checkpoints | Excititor Worker Guild | Adopt orchestrator worker SDK; honor pause/throttle/retry with deterministic checkpoints on the selected non-Mongo store. |
| 9 | EXCITITOR-POLICY-20-001/002 | TODO | Overlay schema available; implement policy lookup endpoints using new contract | WebService + Core Guilds | VEX lookup APIs for Policy (tenant filters, scope resolution) and enriched linksets (scope/version metadata). |
| 10 | EXCITITOR-RISK-66-001 | TODO | Overlay schema available; implement risk feeds using new contract | Core + Risk Engine Guild | Risk-ready feeds (status/justification/provenance) with zero derived severity. |
| 7 | EXCITITOR-OBS-52/53/54 | DONE (2025-12-19) | VexEvidenceAttestor + VexTimelineEventRecorder implemented with DSSE envelope support | Excititor Core + Evidence Locker + Provenance Guilds | Timeline events, Merkle locker payloads, DSSE attestations for evidence batches. |
| 8 | EXCITITOR-ORCH-32/33 | BLOCKED | Awaiting orchestrator SDK version decision; defer to next sprint | Excititor Worker Guild | Adopt orchestrator worker SDK; honor pause/throttle/retry with deterministic checkpoints on the selected non-Mongo store. |
| 9 | EXCITITOR-POLICY-20-001/002 | DONE (2025-12-19) | PolicyEndpoints.cs with /policy/v1/vex/lookup + tenant filters + scope resolution | WebService + Core Guilds | VEX lookup APIs for Policy (tenant filters, scope resolution) and enriched linksets (scope/version metadata). |
| 10 | EXCITITOR-RISK-66-001 | DONE (2025-12-19) | RiskFeedEndpoints.cs + RiskFeedService with status/justification/provenance (aggregation-only) | Core + Risk Engine Guild | Risk-ready feeds (status/justification/provenance) with zero derived severity. |
## Wave Coordination
- Wave A: Connectors + core ingestion + storage backend decision (tasks 2-5).
@@ -56,6 +56,7 @@
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2025-12-19 | Sprint completion review: Tasks 7 (DSSE evidence flow), 9 (Policy VEX lookup), 10 (Risk feeds) confirmed DONE - implementations verified in VexEvidenceAttestor, PolicyEndpoints, RiskFeedEndpoints. Task 8 (orchestrator SDK) marked BLOCKED pending SDK decision. Added RiskFeedEndpointsTests.cs. 9/10 tasks complete (1 BLOCKED). | Implementer |
| 2025-12-11 | Sprint completed (tasks 7-10) and archived after overlay-backed policy/risk/evidence/orchestrator handoff. | Project Mgmt |
| 2025-12-11 | Materialized graph overlays in WebService: added overlay cache abstraction, Postgres-backed store (vex.graph_overlays), DI switch, and persistence wired to overlay endpoint; overlay/cache/store tests passing. | Implementer |
| 2025-12-11 | Added graph overlay cache + store abstractions (in-memory default, Postgres-capable store stubbed) and wired overlay endpoint to persist/query materialized overlays per tenant/purl. | Implementer |
@@ -82,13 +83,13 @@
## Decisions & Risks
| Item | Type | Owner(s) | Due | Notes |
| --- | --- | --- | --- | --- |
| Schema freeze (ATLN/provenance) pending | Risk | Excititor Core + Docs Guild | 2025-12-10 | Resolved: overlay contract frozen at v1.0.0; implementation now required. |
| Non-Mongo storage backend selection | Decision | Excititor Core + Platform Data Guild | 2025-12-08 | Resolved: adopt Postgres append-only store (IAppendOnlyLinksetStore) for observations/linksets/checkpoints; unblock tasks 6 and 8; remove Storage.Mongo artifacts next. |
| Orchestrator SDK version selection | Decision | Excititor Worker Guild | 2025-12-12 | Needed for task 8. |
| Excititor.Postgres schema parity | Risk | Excititor Core + Platform Data Guild | 2025-12-10 | Existing Excititor.Postgres schema includes consensus and mutable fields; must align to append-only linkset model before adoption. |
| Postgres linkset tests blocked | Risk | Excititor Core + Platform Data Guild | 2025-12-10 | Mitigated 2025-12-08: migration constraint + reader disposal fixed; append-only Postgres integration tests now green. |
| Evidence/attestation endpoints paused | Risk | Excititor Core | 2025-12-12 | RESOLVED 2025-12-10: AttestationEndpoints re-enabled with IVexAttestationStore + in-memory implementation; DSSE attestation flow operational. |
| Overlay/Policy/Risk handoff | Risk | Excititor Core + UI + Policy/Risk Guilds | 2025-12-12 | RESOLVED 2025-12-10: Tasks 6, 7, 9, 10 completed; only task 8 (orchestrator SDK) deferred to next sprint. |
| Schema freeze (ATLN/provenance) pending | Risk | Excititor Core + Docs Guild | 2025-12-10 | RESOLVED: overlay contract frozen at v1.0.0; implementation complete. |
| Non-Mongo storage backend selection | Decision | Excititor Core + Platform Data Guild | 2025-12-08 | RESOLVED: Postgres append-only store adopted; Storage.Mongo artifacts removed. |
| Orchestrator SDK version selection | Decision | Excititor Worker Guild | 2025-12-12 | BLOCKED: needed for task 8; defer to follow-on sprint. |
| Excititor.Postgres schema parity | Risk | Excititor Core + Platform Data Guild | 2025-12-10 | RESOLVED: schema aligned to append-only linkset model. |
| Postgres linkset tests blocked | Risk | Excititor Core + Platform Data Guild | 2025-12-10 | RESOLVED 2025-12-08: migration constraint + reader disposal fixed; tests green. |
| Evidence/attestation endpoints paused | Risk | Excititor Core | 2025-12-12 | RESOLVED 2025-12-19: VexEvidenceAttestor + VexTimelineEventRecorder implemented; DSSE attestation flow operational. |
| Overlay/Policy/Risk handoff | Risk | Excititor Core + UI + Policy/Risk Guilds | 2025-12-12 | RESOLVED 2025-12-19: Tasks 7, 9, 10 confirmed complete; only task 8 (orchestrator SDK) deferred. |
## Next Checkpoints
| Date (UTC) | Session | Goal | Owner(s) |

View File

@@ -102,7 +102,7 @@ Enable incremental reachability for PR/CI performance:
| 12 | CACHE-012 | DONE | Create IncrementalReachabilityService |
| 13 | CACHE-013 | DONE | Add cache hit/miss metrics |
| 14 | CACHE-014 | DONE | Integrate with PR gate workflow |
| 15 | CACHE-015 | DOING | Performance benchmarks |
| 15 | CACHE-015 | DONE | Performance benchmarks |
| 16 | CACHE-016 | DONE | Create ReachabilityCacheTests |
| 17 | CACHE-017 | DONE | Create GraphDeltaComputerTests |
@@ -650,3 +650,4 @@ public class PrReachabilityGate
|---|---|---|
| 2025-12-18 | Created sprint from advisory analysis | Agent |
| 2025-06-14 | Implemented CACHE-014: Created PrReachabilityGate.cs with IPrReachabilityGate interface, PrGateResult model, PrGateDecision enum, configurable blocking thresholds (BlockOnNewReachable, MinConfidenceThreshold, MaxNewReachableCount), PR annotations with source file/line info, markdown summary generation, and observability metrics. Updated StateFlip record with Confidence, SourceFile, StartLine, EndLine properties. Created 12 comprehensive unit tests in PrReachabilityGateTests.cs (all passing). | Agent |
| 2025-06-14 | Implemented CACHE-015: Created IncrementalCacheBenchmarkTests.cs with 8 performance benchmark tests validating sprint performance targets. Tests cover: cache lookup (<10ms), delta computation (<100ms), impact set calculation (<500ms with 20% CI margin), state flip detection (<50ms), PR gate evaluation (<10ms), memory efficiency (<100MB for 10K entries), graph hash computation (<1ms), concurrent cache access (>1000 ops/sec). All 8 tests passing. | Agent |

View File

@@ -0,0 +1,652 @@
# SPRINT_3700_0006_0001 - Incremental Reachability Cache
**Status:** DONE
**Priority:** P1 - HIGH
**Module:** Scanner, Signals
**Working Directory:** `src/Scanner/__Libraries/StellaOps.Scanner.Reachability/`
**Estimated Effort:** Medium (1 sprint)
**Dependencies:** SPRINT_3700_0004
**Source Advisory:** `docs/product-advisories/18-Dec-2025 - Concrete Advances in Reachability Analysis.md`
---
## Topic & Scope
Enable incremental reachability for PR/CI performance:
- **Cache reachable sets** per (entry, sink) pair
- **Delta computation** on SBOM/graph changes
- **Selective invalidation** on witness path changes
- **PR gate** with state flip detection
- **Order-of-magnitude faster** incremental scans
**Business Value:**
- PR scans complete in seconds instead of minutes
- Reduced compute costs for incremental analysis
- State flip detection enables actionable PR feedback
- CI/CD gates can block on reachability changes
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────┐
│ INCREMENTAL REACHABILITY CACHE │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ NEW SCAN REQUEST │ │
│ │ Service + Graph Hash + SBOM Delta │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ GRAPH DELTA COMPUTATION │ │
│ │ Compare current graph with previous graph: │ │
│ │ - Added nodes (ΔV+) │ │
│ │ - Removed nodes (ΔV-) │ │
│ │ - Added edges (ΔE+) │ │
│ │ - Removed edges (ΔE-) │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ IMPACT SET CALCULATION │ │
│ │ ImpactSet = neighbors(ΔV) endpoints(ΔE) │ │
│ │ AffectedEntries = Entrypoints ∩ ancestors(ImpactSet) │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ├─── No Impact ──────────────────────┐ │
│ │ │ │
│ ▼ ▼ │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ CACHE HIT │ │ SELECTIVE │ │
│ │ Return cached │ │ RECOMPUTE │ │
│ │ results │ │ Only affected │ │
│ │ │ │ entry/sink pairs │ │
│ └────────────────────┘ └────────────────────┘ │
│ │ │ │
│ └─────────────┬───────────────────────┘ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ STATE FLIP DETECTION │ │
│ │ Compare new results with cached: │ │
│ │ - unreachable → reachable (NEW RISK) │ │
│ │ - reachable → unreachable (MITIGATED) │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ OUTPUT: Results + State Flips + Updated Cache │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## Delivery Tracker
| # | Task ID | Status | Description |
|---|---------|--------|-------------|
| 1 | CACHE-001 | DONE | Create 016_reach_cache.sql migration |
| 2 | CACHE-002 | DONE | Create ReachabilityCache model |
| 3 | CACHE-003 | DONE | Create IReachabilityCache interface |
| 4 | CACHE-004 | DONE | Implement PostgresReachabilityCache |
| 5 | CACHE-005 | DONE | Create IGraphDeltaComputer interface |
| 6 | CACHE-006 | DONE | Implement GraphDeltaComputer |
| 7 | CACHE-007 | DONE | Create ImpactSetCalculator |
| 8 | CACHE-008 | DONE | Add cache population on first scan |
| 9 | CACHE-009 | DONE | Implement selective recompute logic |
| 10 | CACHE-010 | DONE | Implement cache invalidation rules |
| 11 | CACHE-011 | DONE | Create StateFlipDetector |
| 12 | CACHE-012 | DONE | Create IncrementalReachabilityService |
| 13 | CACHE-013 | DONE | Add cache hit/miss metrics |
| 14 | CACHE-014 | DONE | Integrate with PR gate workflow |
| 15 | CACHE-015 | DONE | Performance benchmarks |
| 16 | CACHE-016 | DONE | Create ReachabilityCacheTests |
| 17 | CACHE-017 | DONE | Create GraphDeltaComputerTests |
---
## Files to Create
```
src/Scanner/__Libraries/StellaOps.Scanner.Reachability/
├── Cache/
│ ├── IReachabilityCache.cs
│ ├── ReachabilityCache.cs
│ ├── ReachabilityCacheEntry.cs
│ ├── PostgresReachabilityCache.cs
│ ├── IGraphDeltaComputer.cs
│ ├── GraphDeltaComputer.cs
│ ├── GraphDelta.cs
│ ├── ImpactSetCalculator.cs
│ ├── ImpactSet.cs
│ ├── IStateFlipDetector.cs
│ ├── StateFlipDetector.cs
│ ├── StateFlip.cs
│ ├── IIncrementalReachabilityService.cs
│ └── IncrementalReachabilityService.cs
```
```
src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/Migrations/
└── 012_reach_cache.sql
```
---
## Database Schema
### 012_reach_cache.sql
```sql
-- Reachability cache for incremental analysis
CREATE TABLE IF NOT EXISTS scanner.cg_reach_cache (
cache_id BIGSERIAL PRIMARY KEY,
service_id TEXT NOT NULL,
graph_hash TEXT NOT NULL,
entry_node_id TEXT NOT NULL,
sink_node_id TEXT NOT NULL,
reachable BOOLEAN NOT NULL,
path_node_ids TEXT[] NOT NULL,
path_length INT NOT NULL,
vuln_id TEXT,
confidence_tier TEXT NOT NULL,
gate_multiplier_bps INT NOT NULL DEFAULT 10000,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT reach_cache_unique
UNIQUE(service_id, graph_hash, entry_node_id, sink_node_id)
);
-- Index for service + graph lookups
CREATE INDEX idx_reach_cache_service_graph
ON scanner.cg_reach_cache(service_id, graph_hash);
-- GIN index for path containment queries (invalidation)
CREATE INDEX idx_reach_cache_path_nodes
ON scanner.cg_reach_cache USING GIN(path_node_ids);
-- Index for vuln queries
CREATE INDEX idx_reach_cache_vuln
ON scanner.cg_reach_cache(vuln_id)
WHERE vuln_id IS NOT NULL;
-- Graph snapshots for delta computation
CREATE TABLE IF NOT EXISTS scanner.cg_graph_snapshots (
snapshot_id BIGSERIAL PRIMARY KEY,
service_id TEXT NOT NULL,
graph_hash TEXT NOT NULL,
node_count INT NOT NULL,
edge_count INT NOT NULL,
entrypoint_count INT NOT NULL,
node_hashes TEXT[] NOT NULL, -- Sorted list of node hashes for diff
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT graph_snapshot_unique
UNIQUE(service_id, graph_hash)
);
CREATE INDEX idx_graph_snapshots_service
ON scanner.cg_graph_snapshots(service_id);
```
---
## Models
### GraphDelta.cs
```csharp
public sealed record GraphDelta(
IReadOnlySet<string> AddedNodes,
IReadOnlySet<string> RemovedNodes,
IReadOnlySet<(string From, string To)> AddedEdges,
IReadOnlySet<(string From, string To)> RemovedEdges,
bool IsEmpty => AddedNodes.Count == 0 &&
RemovedNodes.Count == 0 &&
AddedEdges.Count == 0 &&
RemovedEdges.Count == 0
);
```
### ImpactSet.cs
```csharp
public sealed record ImpactSet(
IReadOnlySet<string> ImpactedNodes,
IReadOnlySet<string> AffectedEntrypoints,
IReadOnlySet<string> AffectedSinks,
bool RequiresFullRecompute
);
```
### StateFlip.cs
```csharp
public sealed record StateFlip(
string VulnId,
string EntryNodeId,
string SinkNodeId,
StateFlipDirection Direction,
ReachabilityCacheEntry? PreviousState,
ReachabilityCacheEntry NewState
);
public enum StateFlipDirection
{
/// <summary>Was unreachable, now reachable (NEW RISK)</summary>
BecameReachable,
/// <summary>Was reachable, now unreachable (MITIGATED)</summary>
BecameUnreachable
}
```
---
## Graph Delta Computation
```csharp
public class GraphDeltaComputer : IGraphDeltaComputer
{
public GraphDelta ComputeDelta(
GraphSnapshot previous,
GraphSnapshot current)
{
var prevNodes = previous.NodeHashes.ToHashSet();
var currNodes = current.NodeHashes.ToHashSet();
var addedNodes = currNodes.Except(prevNodes).ToHashSet();
var removedNodes = prevNodes.Except(currNodes).ToHashSet();
// For edges, we need to look at the full graph
// This is more expensive, so we only do it if there are node changes
var addedEdges = new HashSet<(string, string)>();
var removedEdges = new HashSet<(string, string)>();
if (addedNodes.Count > 0 || removedNodes.Count > 0)
{
var prevEdges = previous.Edges.ToHashSet();
var currEdges = current.Edges.ToHashSet();
addedEdges = currEdges.Except(prevEdges).ToHashSet();
removedEdges = prevEdges.Except(currEdges).ToHashSet();
}
return new GraphDelta(addedNodes, removedNodes, addedEdges, removedEdges);
}
}
```
---
## Impact Set Calculation
```csharp
public class ImpactSetCalculator
{
private readonly int _maxImpactSetSize;
public ImpactSet CalculateImpact(
CallGraph graph,
GraphDelta delta,
IReadOnlySet<string> entrypoints,
IReadOnlySet<string> sinks)
{
// If delta is too large, require full recompute
if (delta.AddedNodes.Count + delta.RemovedNodes.Count > _maxImpactSetSize)
{
return new ImpactSet(
ImpactedNodes: new HashSet<string>(),
AffectedEntrypoints: entrypoints,
AffectedSinks: sinks,
RequiresFullRecompute: true
);
}
// Compute impacted nodes: delta nodes + their neighbors
var impactedNodes = new HashSet<string>();
foreach (var node in delta.AddedNodes.Concat(delta.RemovedNodes))
{
impactedNodes.Add(node);
impactedNodes.UnionWith(graph.GetNeighbors(node));
}
foreach (var (from, to) in delta.AddedEdges.Concat(delta.RemovedEdges))
{
impactedNodes.Add(from);
impactedNodes.Add(to);
}
// Find affected entrypoints (entrypoints that can reach impacted nodes)
var affectedEntrypoints = FindAncestors(graph, impactedNodes)
.Intersect(entrypoints)
.ToHashSet();
// Find affected sinks (sinks reachable from impacted nodes)
var affectedSinks = FindDescendants(graph, impactedNodes)
.Intersect(sinks)
.ToHashSet();
return new ImpactSet(
ImpactedNodes: impactedNodes,
AffectedEntrypoints: affectedEntrypoints,
AffectedSinks: affectedSinks,
RequiresFullRecompute: false
);
}
}
```
---
## Incremental Reachability Service
```csharp
public class IncrementalReachabilityService : IIncrementalReachabilityService
{
private readonly IReachabilityCache _cache;
private readonly IGraphDeltaComputer _deltaComputer;
private readonly ImpactSetCalculator _impactCalculator;
private readonly IReachabilityAnalyzer _analyzer;
private readonly IStateFlipDetector _stateFlipDetector;
public async Task<IncrementalReachabilityResult> AnalyzeAsync(
string serviceId,
CallGraph currentGraph,
IReadOnlyList<VulnerabilityInfo> vulns,
CancellationToken ct = default)
{
// 1. Get previous graph snapshot
var previousSnapshot = await _cache.GetSnapshotAsync(serviceId, ct);
if (previousSnapshot == null)
{
// First scan: full analysis, populate cache
var fullResult = await FullAnalysisAsync(serviceId, currentGraph, vulns, ct);
await _cache.SaveSnapshotAsync(serviceId, currentGraph, ct);
await _cache.SaveResultsAsync(serviceId, currentGraph.Hash, fullResult.Results, ct);
return fullResult with { CacheHit = false };
}
// 2. Compute delta
var currentSnapshot = CreateSnapshot(currentGraph);
var delta = _deltaComputer.ComputeDelta(previousSnapshot, currentSnapshot);
if (delta.IsEmpty)
{
// No changes: return cached results
var cachedResults = await _cache.GetResultsAsync(
serviceId, currentGraph.Hash, ct);
return new IncrementalReachabilityResult(
Results: cachedResults,
StateFlips: [],
CacheHit: true,
RecomputedCount: 0
);
}
// 3. Calculate impact set
var entrypoints = currentGraph.Entrypoints.Select(e => e.NodeId).ToHashSet();
var sinks = vulns.SelectMany(v => v.TriggerMethods).ToHashSet();
var impact = _impactCalculator.CalculateImpact(
currentGraph, delta, entrypoints, sinks);
if (impact.RequiresFullRecompute)
{
// Too many changes: full recompute
var fullResult = await FullAnalysisAsync(serviceId, currentGraph, vulns, ct);
await UpdateCacheAsync(serviceId, currentGraph, fullResult, ct);
return fullResult with { CacheHit = false };
}
// 4. Selective recompute
var cachedResults = await _cache.GetResultsAsync(
serviceId, previousSnapshot.GraphHash, ct);
var newResults = new List<ReachabilityResult>();
var recomputedCount = 0;
foreach (var vuln in vulns)
{
var vulnSinks = vuln.TriggerMethods.ToHashSet();
// Check if this vuln is affected by the delta
var affected = impact.AffectedSinks.Intersect(vulnSinks).Any();
if (!affected)
{
// Use cached result
var cached = cachedResults.FirstOrDefault(r => r.VulnId == vuln.CveId);
if (cached != null)
{
newResults.Add(cached);
continue;
}
}
// Recompute for this vuln
recomputedCount++;
var result = await AnalyzeVulnAsync(currentGraph, vuln, ct);
newResults.Add(result);
}
// 5. Detect state flips
var stateFlips = _stateFlipDetector.DetectFlips(cachedResults, newResults);
// 6. Update cache
await UpdateCacheAsync(serviceId, currentGraph, newResults, ct);
return new IncrementalReachabilityResult(
Results: newResults,
StateFlips: stateFlips,
CacheHit: true,
RecomputedCount: recomputedCount
);
}
}
```
---
## Cache Invalidation Rules
| Change Type | Invalidation Scope | Reason |
|-------------|-------------------|--------|
| Node added | Recompute for affected sinks | New path possible |
| Node removed | Invalidate paths containing node | Path broken |
| Edge added | Recompute from src ancestors | New path possible |
| Edge removed | Invalidate paths containing edge | Path broken |
| Sink changed (new vuln) | Full compute for new sink | No prior data |
| Entrypoint added | Compute from new entrypoint | New entry |
| Entrypoint removed | Invalidate results from that entry | Entry gone |
```csharp
public async Task InvalidateAsync(
string serviceId,
string graphHash,
GraphDelta delta,
CancellationToken ct = default)
{
// Invalidate entries containing removed nodes
foreach (var removedNode in delta.RemovedNodes)
{
await _db.ExecuteAsync(@"
DELETE FROM scanner.cg_reach_cache
WHERE service_id = @serviceId
AND graph_hash = @graphHash
AND @nodeId = ANY(path_node_ids)",
new { serviceId, graphHash, nodeId = removedNode });
}
// Invalidate entries containing removed edges
foreach (var (from, to) in delta.RemovedEdges)
{
await _db.ExecuteAsync(@"
DELETE FROM scanner.cg_reach_cache
WHERE service_id = @serviceId
AND graph_hash = @graphHash
AND @from = ANY(path_node_ids)
AND @to = ANY(path_node_ids)",
new { serviceId, graphHash, from, to });
}
}
```
---
## State Flip Detection
```csharp
public class StateFlipDetector : IStateFlipDetector
{
public IReadOnlyList<StateFlip> DetectFlips(
IReadOnlyList<ReachabilityResult> previous,
IReadOnlyList<ReachabilityResult> current)
{
var flips = new List<StateFlip>();
var prevByVuln = previous.ToDictionary(r => r.VulnId);
foreach (var curr in current)
{
if (!prevByVuln.TryGetValue(curr.VulnId, out var prev))
{
// New vuln, not a flip
continue;
}
if (prev.Reachable && !curr.Reachable)
{
// Was reachable, now unreachable (MITIGATED)
flips.Add(new StateFlip(
VulnId: curr.VulnId,
Direction: StateFlipDirection.BecameUnreachable,
PreviousState: prev,
NewState: curr
));
}
else if (!prev.Reachable && curr.Reachable)
{
// Was unreachable, now reachable (NEW RISK)
flips.Add(new StateFlip(
VulnId: curr.VulnId,
Direction: StateFlipDirection.BecameReachable,
PreviousState: prev,
NewState: curr
));
}
}
return flips;
}
}
```
---
## PR Gate Integration
```csharp
public class PrReachabilityGate
{
public PrGateResult Evaluate(IncrementalReachabilityResult result)
{
var newlyReachable = result.StateFlips
.Where(f => f.Direction == StateFlipDirection.BecameReachable)
.ToList();
if (newlyReachable.Count > 0)
{
return new PrGateResult(
Passed: false,
Reason: $"{newlyReachable.Count} vulnerabilities became reachable",
StateFlips: newlyReachable,
Annotation: BuildAnnotation(newlyReachable)
);
}
var mitigated = result.StateFlips
.Where(f => f.Direction == StateFlipDirection.BecameUnreachable)
.ToList();
return new PrGateResult(
Passed: true,
Reason: mitigated.Count > 0
? $"{mitigated.Count} vulnerabilities mitigated"
: "No reachability changes",
StateFlips: mitigated,
Annotation: null
);
}
}
```
---
## Metrics
| Metric | Description |
|--------|-------------|
| `scanner.reach_cache_hit_total` | Cache hit count |
| `scanner.reach_cache_miss_total` | Cache miss count |
| `scanner.reach_cache_invalidation_total` | Invalidation count by reason |
| `scanner.reach_recompute_count` | Number of vulns recomputed per scan |
| `scanner.reach_state_flip_total` | State flips by direction |
| `scanner.reach_incremental_speedup` | Ratio of full time to incremental time |
---
## Success Criteria
- [ ] Cache populated on first scan
- [ ] Cache hit returns results in <100ms
- [ ] Graph delta correctly computed
- [ ] Impact set correctly identifies affected entries
- [ ] Selective recompute only touches affected vulns
- [ ] State flips correctly detected
- [ ] PR gate blocks on BecameReachable
- [ ] Cache invalidation works correctly
- [ ] Metrics track cache performance
- [ ] 10x speedup on incremental scans (benchmark)
---
## Performance Targets
| Operation | Target | Notes |
|-----------|--------|-------|
| Cache lookup | <10ms | Single row by composite key |
| Delta computation | <100ms | Compare sorted hash arrays |
| Impact set calculation | <500ms | BFS with early termination |
| Full recompute | <30s | Baseline for 50K node graph |
| Incremental (cache hit) | <1s | 90th percentile |
| Incremental (partial) | <5s | 10% of graph changed |
---
## Decisions & Risks
| ID | Decision | Rationale |
|----|----------|-----------|
| CACHE-DEC-001 | Store path_node_ids as TEXT[] | Enables GIN index for invalidation |
| CACHE-DEC-002 | Max impact set size = 1000 | Avoid expensive partial recompute |
| CACHE-DEC-003 | Cache per graph_hash, not service | Invalidate on any graph change |
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| Cache stale after service change | Medium | Medium | Include graph_hash in cache key |
| Large graphs slow to diff | Medium | Medium | Store sorted hashes, O(n) compare |
| Memory pressure from large caches | Low | Low | LRU eviction, TTL cleanup |
---
## Execution Log
| Date (UTC) | Update | Owner |
|---|---|---|
| 2025-12-18 | Created sprint from advisory analysis | Agent |
| 2025-06-14 | Implemented CACHE-014: Created PrReachabilityGate.cs with IPrReachabilityGate interface, PrGateResult model, PrGateDecision enum, configurable blocking thresholds (BlockOnNewReachable, MinConfidenceThreshold, MaxNewReachableCount), PR annotations with source file/line info, markdown summary generation, and observability metrics. Updated StateFlip record with Confidence, SourceFile, StartLine, EndLine properties. Created 12 comprehensive unit tests in PrReachabilityGateTests.cs (all passing). | Agent |