Reachability Drift Alert Flow
Audience: developers and release engineers wiring reachability-drift detection into CI gates, and operators triaging drift results. Read the implementation-status banner first — it separates the two real mechanisms (Scanner scan-vs-scan drift and Signals runtime updates) from the roadmap (drift-driven Notify alerts).
Implementation status (reconciled against
src/, 2026-05-31). Two distinct reachability-change mechanisms exist in code, and this document originally blended them into a single fictional pipeline:
- Scanner reachability drift (implemented).
StellaOps.Scanner.ReachabilityDriftcompares the call graph of a base scan against a head scan and reports sinks that became reachable / unreachable. Exposed viaGET /api/v1/scans/{scanId}/driftandGET /api/v1/drift/{driftId}/sinkson the Scanner WebService (src/Scanner/StellaOps.Scanner.WebService/Endpoints/ReachabilityDriftEndpoints.cs). This is scan-vs-scan diffing — not runtime-vs-baseline state tracking.- Signals runtime updates (implemented). When runtime observations change, Signals emits a
RuntimeUpdatedEventcarrying a previous/new reachability-lattice state (as short codes) and atriggerReanalysisflag (src/Signals/StellaOps.Signals/Models/RuntimeUpdatedEvent.cs). The v1 reachability lattice itself lives insrc/Signals/StellaOps.Signals/Lattice/. (This 8-state lattice is not the “K4 lattice” — see the naming correction in the Reachability Lattice section.)Policy gating of drift is implemented (
DriftGateEvaluator,POST /api/v1/policy/gate/evaluate); drift attestation is implemented (reachability-drift@v1DSSE predicate). A dedicated Notify event kind for reachability drift does not exist (NotifyEventKindshas noreachability.drift), and there is no Scheduler service that periodically polls for drift — the Scanner drift endpoint is on-demand and Signals sets a reanalysis flag on the event. Sections below mark roadmap/aspirational content as DRAFT; runnable behaviour is grounded in the cited source.
Overview
The Reachability Drift Alert Flow describes how StellaOps detects changes in code reachability that affect vulnerability risk assessments. When runtime observations (Signals) or a new scan’s call graph (Scanner) reveal that previously unreachable vulnerable code has become reachable (or vice versa), the change is surfaced as drift, can gate a release (Policy), and is recorded as a signed attestation (Attestor).
Business Value: Catch newly reachable vulnerabilities before they’re exploited, and reduce alert fatigue by treating sinks that become unreachable as a downgrade.
Actors
| Actor | Type | Role |
|---|---|---|
| Signals | Service | Ingests runtime/callgraph signals; maintains v1 reachability-lattice state; emits RuntimeUpdatedEvent with triggerReanalysis |
| Scanner | Service | Computes scan-vs-scan reachability drift (StellaOps.Scanner.ReachabilityDrift); exposes drift + sinks endpoints |
| ReachGraph | Service | Content-addressed reachability graph storage, slice queries, and replay verification (v1/reachgraphs) — not a drift detector |
| Policy Engine | Service | Evaluates the drift gate (DriftGateEvaluator) for release decisions (Allow/Warn/Block) |
| Attestor | Service | Signs reachability-drift@v1 DSSE attestations over the drift result |
| Notify | Service | DRAFT — alert delivery; no reachability.drift event kind is wired today |
| Scheduler / JobEngine | Service | DRAFT — periodic drift polling is not implemented (drift is on-demand) |
Prerequisites
- For Scanner drift: two scans of the same image (a base scan and a head scan) with call graph snapshots persisted for the requested language.
- For Signals runtime updates: runtime instrumentation deployed (eBPF agent or container-inspect runtime agent) feeding the runtime ingest endpoints.
- A baseline (Policy gate baseline snapshot) when using the release gate.
- Drift gate options configured (
DriftGateOptions); alert channels configured if/when Notify wiring lands.
Reachability Lattice (v1)
Naming correction (reconciled 2026-05-31). This 8-state lattice is not the “K4 lattice.” The source calls it the v1 reachability lattice (
ReachabilityLatticeState, doc-comment: “the v1 reachability lattice states”). “K4” is a separate concept in the codebase: a four-valued Belnap knowledge logic (Unknown/True/False/Conflict) insrc/Policy/__Libraries/StellaOps.Policy/TrustLattice/K4Lattice.cs(also mirrored inStellaOps.Policy.Determinization/Scoring/K4Lattice.cs), used by the policy Trust Lattice Engine — it has four values, not eight, and is unrelated to the reachability state machine below. Earlier revisions of this doc incorrectly equated the two.
The reachability state is the v1 reachability lattice defined in src/Signals/StellaOps.Signals/Lattice/ReachabilityLatticeState.cs. It is a bounded lattice with Unknown at the bottom and Contested at the top; evidence from multiple sources is combined with the Join (least upper bound) operation in ReachabilityLattice.cs (which uses an explicit 8×8 join table). The eight states and their short codes:
| State | Code | Meaning (from source) |
|---|---|---|
Unknown | U | Bottom element — no evidence available |
StaticallyReachable | SR | Static analysis suggests a path exists |
StaticallyUnreachable | SU | Static analysis finds no path |
RuntimeObserved | RO | Runtime probe/hit confirms execution |
RuntimeUnobserved | RU | Runtime probe active but no hit observed |
ConfirmedReachable | CR | Static and runtime agree reachable (Join(SR, RO)) |
ConfirmedUnreachable | CU | Static and runtime agree unreachable (Join(SU, RU)) |
Contested | X | Top element — static and runtime evidence conflict |
ImpliesReachable() is true for SR, RO, CR; ImpliesUnreachable() is true for SU, RU, CU; IsConfirmed() is true for CR/CU only.
Drift directions (Signals runtime updates)
A RuntimeUpdatedEvent carries previousState → newState (as reachability-lattice short codes) and the factory decides whether to set triggerReanalysis (RuntimeUpdatedEventFactory.ShouldTriggerReanalysis):
| Condition | Reanalysis reason |
|---|---|
UpdateType == ExploitTelemetry | exploit_telemetry_detected |
previousState != newState | state_change_<from>_to_<to> |
fromRuntime and confidence >= 0.9 | high_confidence_runtime_observation |
UpdateType == NewCallPath | new_call_path_observed |
RuntimeUpdateType values are NewObservation, StateChange, ConfidenceIncrease, NewCallPath, ExploitTelemetry. The risk-impact / alert-priority mapping below is DRAFT — the source does not currently assign a priority per transition; it only sets the boolean triggerReanalysis flag.
DRAFT — not implemented as a priority table. The following is a roadmap view of how transitions could be prioritized for alerting. Treat as aspirational until a priority field exists on the event.
| From → To | Risk Impact | Alert Priority |
|---|---|---|
U → SR | Increased | Medium |
U → RO | Increased | High |
SU → SR | Increased | Medium |
SR → RO / SR → CR | Confirmed | High |
SR → SU | Decreased | Low |
RO → RU | Decreased | Medium |
any → CU | Decreased | Low |
any → X (Contested) | Review needed | High |
Flow Diagram
There are two real entry paths. The diagram shows the implemented Scanner scan-vs-scan drift path (solid), with the Signals runtime-update path and the Notify alert leg annotated as separate / DRAFT.
┌─────────────────────────────────────────────────────────────────────────────────┐
│ Reachability Drift — Scanner scan-vs-scan path │
└─────────────────────────────────────────────────────────────────────────────────┘
┌────────┐ ┌──────────────────────────┐ ┌────────┐ ┌──────────┐ ┌────────┐
│ Caller │ │ Scanner WebService │ │ Policy │ │ Attestor │ │ Notify │
│ (CI/UI)│ │ (ReachabilityDrift) │ │ (gate) │ │ (DSSE) │ │ DRAFT │
└───┬────┘ └────────────┬─────────────┘ └───┬────┘ └────┬─────┘ └───┬────┘
│ GET /scans/{id}/drift │ │ │ │
│ ?baseScanId=&language= │ │ │ │
│──────────────────────────>│ │ │ │
│ │ load base+head │ │ │
│ │ callgraph snapshots │ │ │
│ │───┐ │ │ │
│ │<──┘ │ │ │
│ │ extract code-change │ │ │
│ │ facts; Detect() │ │ │
│ │───┐ diff reachable │ │ │
│ │<──┘ sink sets │ │ │
│ ReachabilityDriftResult │ store result │ │ │
│<──────────────────────────│ │ │ │
│ │ │ │ │
│ POST /api/v1/policy/gate/evaluate (delta-based) │ │ │
│────────────────────────────────────────────────>│ │ │
│ │ Allow/Warn/Block │ │ │
│<─────────────────────────────────────────────────│ │ │
│ │ │ │ │
│ (optional) sign drift │ CreateAttestation │ │ │
│───────────────────────────────────────────────────────────────>│ │
│ │ reachability-drift@v1 DSSE │ │
│<───────────────────────────────────────────────────────────────│ │
│ │ │ │ │
│ ── DRAFT: drift-driven Notify alert is not wired (no event kind) ───────>│
Signals runtime-update path (separate, implemented in Signals): runtime observations are ingested (POST /api/v1/runtime/observations, /signals/runtime-facts, etc.); when the reachability-lattice state changes Signals emits a RuntimeUpdatedEvent (via RuntimeFactsIngestionService.EmitRuntimeUpdatedEventAsync → IEventsPublisher) whose triggerReanalysis flag signals downstream consumers to re-analyze. There is no Scheduler component polling for drift in this path either.
Step-by-Step (Scanner scan-vs-scan drift — implemented)
1. Request drift for a scan
A caller (CI step, UI, or the gate flow) asks the Scanner WebService for the reachability drift of a head scan relative to a base scan. The language must be one wired into the drift pipeline — the request is routed through IDriftLanguageRouter, whose DriftLanguageRouter seeds five tier-1 languages (dotnet, java, go, python, node) plus five tier-2 languages (bun, deno, php, ruby, rust), each with aliases (e.g. csharp/c#/net → dotnet, javascript/ts/nodejs → node). An omitted language defaults to dotnet (LegacyDefaultLanguage, pinned for pre-Sprint-031 backward compatibility); unsupported languages return 400 with the supported set listed in the problem detail.
GET /api/v1/scans/{scanId}/drift?baseScanId={baseScanId}&language=dotnet&includeFullPath=false
Authorization: Bearer <token with scanner:read>
The endpoint is protected by the ScannerPolicies.ScansRead policy, which accepts either the canonical scanner:read scope (StellaOpsScopes.ScannerRead) or the Scanner-internal scanner.scans.read scope (Program.cs → AddStellaOpsAnyScopePolicy(ScannerPolicies.ScansRead, ScannerAuthorityScopes.ScansRead, StellaOpsScopes.ScannerRead)). There is no scans:read scope in the canonical catalog.
If baseScanId is omitted, the endpoint returns the latest previously-stored drift result for that head scan/language (or 404 if none exists). When baseScanId is supplied, the endpoint recomputes (see below). Tenant is resolved from the request context. Source: ReachabilityDriftEndpoints.HandleGetDriftAsync.
2. Load base and head call graph snapshots
The endpoint resolves both scan snapshots via IScanCoordinator, then loads the latest persisted call graph snapshot for each scan + language from ICallGraphSnapshotRepository. Missing scans or snapshots yield 404 with a specific problem detail. The base and head graphs must share the same language or Detect() throws ArgumentException (surfaced as 400).
3. Extract code-change facts and detect drift
CodeChangeFactExtractor.Extract(baseGraph, headGraph) produces CodeChangeFact records (added, removed, signature_changed, guard_changed, dependency_changed, visibility_changed). ReachabilityDriftDetector.Detect then runs ReachabilityAnalyzer over both trimmed graphs and diffs the reachable sink-id sets:
newlyReachable= head-reachable sinks NOT base-reachablenewlyUnreachable= base-reachable sinks NOT head-reachable
The result is deterministic: ids are ordered with StringComparer.Ordinal, the resultDigest is sha256 over baseScanId|headScanId|language|reachableIds|unreachableIds, and the id is a deterministic GUID derived from that digest.
{
"id": "00000000-0000-0000-0000-000000000000",
"baseScanId": "scan-abc-base",
"headScanId": "scan-abc-head",
"language": "dotnet",
"detectedAt": "2026-05-30T10:30:01Z",
"newlyReachable": [
{
"id": "11111111-1111-1111-1111-111111111111",
"sinkNodeId": "node:Lib.Template.Render",
"symbol": "Lib.Template.Render",
"sinkCategory": "CmdExec",
"direction": "became_reachable",
"cause": {
"kind": "guard_removed",
"description": "Guard condition removed in Lib.Template.Render",
"changedSymbol": "Lib.Template.Render"
},
"path": {
"entrypoint": { "nodeId": "node:App.Routes.Render", "symbol": "App.Routes.Render" },
"sink": { "nodeId": "node:Lib.Template.Render", "symbol": "Lib.Template.Render" },
"intermediateCount": 1,
"keyNodes": []
},
"associatedVulns": []
}
],
"newlyUnreachable": [],
"resultDigest": "…sha256 hex…",
"totalDriftCount": 1,
"hasMaterialDrift": true
}
hasMaterialDrift is true whenever newlyReachable is non-empty (computed in ReachabilityDriftResult). DriftCause.Kind values are guard_removed, guard_added, new_public_route, visibility_escalated, dependency_upgraded, symbol_removed, unknown. AssociatedVuln (when populated) carries cveId, epss, cvss, vexStatus, packagePurl.
4. Page through drifted sinks
For large results the sinks can be paged by direction:
GET /api/v1/drift/{driftId}/sinks?direction=became_reachable&offset=0&limit=100
Authorization: Bearer <token with scanner:read>
direction accepts became_reachable (aliases newly_reachable/reachable/up) or became_unreachable (aliases newly_unreachable/unreachable/down); limit must be 1–500; unknown driftId returns 404. Source: HandleListSinksAsync.
5. Gate the release (Policy)
The Policy Engine evaluates the drift via POST /api/v1/policy/gate/evaluate (GateEndpoints + DriftGateEvaluator; requires the policy:run scope — StellaOpsScopes.PolicyRun — and a tenant). The request body takes an imageDigest (plus optional baselineRef, repository, tag, missingBaselineMode, allowOverride/overrideJustification, source/ciContext). The endpoint resolves a baseline snapshot (strategy: last-approved default, or previous-build / production-deployed / branch-base / explicit snapshot:), materializes the target snapshot, computes a SecurityStateDelta, and builds the DriftGateContext from the delta’s drivers (new-reachable-cve/new-reachable-path → delta_reachable; removed-reachable-* → delta_unreachable; is_kev/cvss/epss/vex_status pulled from driver Details). Note this gate path consumes the delta, not the Scanner ReachabilityDriftResult object directly. Built-in gates:
- KevReachable — blocks if a KEV is newly reachable (
is_kev = true AND delta_reachable > 0). - AffectedReachable — blocks if newly reachable sinks have VEX status
affected/under_investigation; warns on any other newly reachable paths. - CvssThreshold / EpssThreshold — block when a newly reachable vuln meets/exceeds the configured threshold.
- Custom gates from
DriftGateOptions.Gateswithdelta_reachable,delta_unreachable,is_kev,max_cvss,max_epss,vex_status IN [...]conditions.
The decision is Allow, Warn, or Block (DriftGateDecisionType), mapped to a gate-level GateStatus of Pass / Warn / Fail and exit codes; a Block returns HTTP 403. An override with a justification (≥10 chars) downgrades a Block to Warn and records a bypass audit entry. If gates are disabled or there is no material drift, the decision is Allow.
Terminology note. Policy uses
Allow/Warn/Blockfor the gate decision andPass/Warned/Blocked(PolicyVerdictStatus, alsoGuardedPass) for verdict status. The earlier “WARN → FAIL” phrasing matched neither enum; the CI-facingGateStatusisPass/Warn/Fail.
6. Attest the drift (Attestor)
DriftAttestationService.CreateAttestationAsync builds a stellaops.dev/predicates/reachability-drift@v1 predicate (ReachabilityDriftPredicate) over the drift result and wraps it in a DSSE envelope. It signs via the Signer service when configured (UseSignerService), otherwise a local dev key is used. The predicate records base/target image references, base/head scan ids, newly-reachable/unreachable counts and summaries (including associated CVEs and a path hash), and analysis metadata (scanner name/version, ruleset, base/head graph digests, code-changes digest). Returns an attestation sha256: digest and the canonical envelope JSON. Disabled by default unless DriftAttestationOptions.Enabled.
Step-by-Step (Signals runtime-update path — implemented, separate)
When runtime telemetry changes the reachability-lattice state of a CVE+product subject, Signals emits a RuntimeUpdatedEvent (runtime.updated / runtime.updated@1). The event carries the previous/new lattice state, a confidence score, and a triggerReanalysis flag with a reanalysisReason. The deterministic eventId is runtime-evt-<first16 hex of sha256(subjectKey|evidenceDigest|occurredAtUtc)>.
{
"eventId": "runtime-evt-0123456789abcdef",
"eventType": "runtime.updated",
"version": "1.0.0",
"tenant": "default",
"cveId": "CVE-2024-1234",
"purl": "pkg:npm/lodash@4.17.20",
"subjectKey": "…",
"evidenceDigest": "…sha256…",
"updateType": "StateChange",
"previousState": "SR",
"newState": "RO",
"confidence": 0.95,
"fromRuntime": true,
"runtimeMethod": "ebpf",
"observedNodeHashes": [],
"pathHash": null,
"triggerReanalysis": true,
"reanalysisReason": "state_change_SR_to_RO",
"occurredAtUtc": "2026-05-30T10:30:00Z"
}
Consumers that subscribe to runtime events use triggerReanalysis to decide whether to re-run downstream analysis/policy. The cross-service wiring from this event to a re-scan and then to a user-facing alert is not implemented as a single orchestrated pipeline — there is no Scheduler component polling these events, and Notify has no reachability.drift event kind to deliver such alerts.
Notify alert (DRAFT — not implemented)
DRAFT / roadmap.
NotifyEventKinds(src/Notify/__Libraries/StellaOps.Notify.Models/NotifyEventKinds.cs) containsscanner.report.ready,scanner.scan.completed,scheduler.rescan.delta,attestor.logged,airgap.time.drift,nis2.effectiveness.threshold_breached, and others — but no reachability-drift event kind. The alert payloads below describe a future user-facing alert and do not correspond to a wired Notify channel today. The closest implemented notification trigger isscheduler.rescan.delta, which is a generic rescan-delta event, not a reachability-drift alert.
{
"alert": {
"type": "reachability_drift",
"priority": "high",
"title": "Vulnerability became reachable",
"body": {
"summary": "Sink Lib.Template.Render became reachable between scan-abc-base and scan-abc-head",
"cause": "guard_removed",
"associatedCves": ["CVE-2024-1234"],
"impact": "Drift gate decision: Block",
"action_required": "Triage newly reachable affected vulnerability"
},
"channels": ["slack", "email"]
}
}
Drift Gate Configuration
The implemented configuration is the Policy drift gate under the SmartDiff:Gates section (DriftGateOptions). There is no realtime/batch/hybrid detection-mode toggle in the source — Scanner drift is computed on demand and the gate runs against the resulting delta. The real options:
# SmartDiff:Gates (StellaOps.Policy.Engine.Gates.DriftGateOptions)
SmartDiff:
Gates:
Enabled: true # master switch; false => Allow everything
DefaultAction: Warn # Allow | Warn | Block (when no gate matches)
BlockOnKev: true # block when a KEV becomes reachable
BlockOnAffectedReachable: true # block when affected/under_investigation becomes reachable
AutoEmitVexForUnreachable: true
CvssBlockThreshold: 9.0 # block when newly reachable vuln CVSS >= 9.0
EpssBlockThreshold: 0.5 # block when newly reachable vuln EPSS >= 0.5
Gates: # custom gate definitions
- Id: block-high-epss-network-sink
Condition: "max_epss >= 0.7 AND delta_reachable > 0"
Action: Block # Allow | Warn | Block
Severity: High # Info | Low | Medium | High | Critical
Message: "High-EPSS sink became reachable"
AutoMitigate: false
Custom gate Condition supports delta_reachable, delta_unreachable, is_kev, max_cvss, max_epss, and vex_status IN ['affected', ...], combined with AND/OR (see DriftGateEvaluator.EvaluateCondition).
DRAFT — not implemented. The previous
drift_policypresets (aggressive/balanced/permissive) anddrift_detectionmodes (realtime/batch/hybrid,observation_gap,min_invocations_for_transition) are aspirational and have no counterpart in the source. UseSmartDiff:Gatesabove.
Downgrade Handling
When a sink becomes unreachable between scans it appears in ReachabilityDriftResult.newlyUnreachable with direction: "became_unreachable", and hasMaterialDrift stays false if there are no newly-reachable sinks. The gate treats this as non-blocking (delta_reachable == 0 ⇒ Allow / no warning), and AutoEmitVexForUnreachable (default true) governs auto-emitting VEX candidates for unreachable sinks. There is no time-based observation_gap auto-downgrade and no FAIL → WARN verdict transition in the source.
{
"id": "…drift-result-guid…",
"baseScanId": "scan-abc-base",
"headScanId": "scan-abc-head",
"language": "dotnet",
"newlyReachable": [],
"newlyUnreachable": [
{
"sinkNodeId": "node:Lib.Template.Render",
"symbol": "Lib.Template.Render",
"sinkCategory": "CmdExec",
"direction": "became_unreachable",
"cause": { "kind": "guard_added", "description": "Guard condition added in Lib.Template.Render" }
}
],
"hasMaterialDrift": false
}
Data Contracts
ReachabilityDriftResult (Scanner — implemented)
Source: src/Scanner/__Libraries/StellaOps.Scanner.ReachabilityDrift/Models/DriftModels.cs. JSON is camelCase.
interface ReachabilityDriftResult {
id: string; // deterministic GUID derived from resultDigest
baseScanId: string;
headScanId: string;
language: string;
detectedAt: string; // ISO-8601
newlyReachable: DriftedSink[];
newlyUnreachable: DriftedSink[];
resultDigest: string; // sha256 hex over base|head|language|reachable|unreachable
totalDriftCount: number; // computed: newlyReachable + newlyUnreachable
hasMaterialDrift: boolean; // computed: newlyReachable.length > 0
}
interface DriftedSink {
id: string; // deterministic GUID
sinkNodeId: string;
symbol: string;
sinkCategory: string; // SinkCategory enum (e.g. CmdExec, ...)
direction: 'became_reachable' | 'became_unreachable';
cause: DriftCause;
path: CompressedPath; // entrypoint, sink, intermediateCount, keyNodes, fullPath?
associatedVulns: AssociatedVuln[];
}
interface DriftCause {
kind: 'guard_removed' | 'guard_added' | 'new_public_route'
| 'visibility_escalated' | 'dependency_upgraded'
| 'symbol_removed' | 'unknown';
description: string;
changedSymbol?: string;
changedFile?: string;
changedLine?: number;
codeChangeId?: string; // GUID
}
interface AssociatedVuln {
cveId: string;
epss?: number;
cvss?: number;
vexStatus?: string;
packagePurl?: string;
}
RuntimeUpdatedEvent (Signals — implemented)
Source: src/Signals/StellaOps.Signals/Models/RuntimeUpdatedEvent.cs. JSON is camelCase.
interface RuntimeUpdatedEvent {
eventId: string; // runtime-evt-<16 hex>
eventType: string; // "runtime.updated" | "runtime.updated@1"
version: string; // "1.0.0"
tenant: string;
cveId?: string;
purl?: string;
subjectKey: string;
callgraphId?: string;
evidenceDigest: string; // sha256
updateType: 'NewObservation' | 'StateChange' | 'ConfidenceIncrease'
| 'NewCallPath' | 'ExploitTelemetry';
previousState?: string; // reachability-lattice short code (U/SR/SU/RO/RU/CR/CU/X)
newState: string; // reachability-lattice short code
confidence: number; // 0.0-1.0
fromRuntime: boolean;
runtimeMethod?: string; // e.g. "ebpf", "agent", "probe"
observedNodeHashes: string[];
pathHash?: string;
triggerReanalysis: boolean;
reanalysisReason?: string;
occurredAtUtc: string;
traceId?: string;
}
reachability-drift@v1 attestation predicate (Attestor — implemented)
ReachabilityDriftPredicate (src/Attestor/__Libraries/StellaOps.Attestor.ProofChain/...) carries baseImage, targetImage, baseScanId, headScanId, a drift summary (newlyReachableCount/newlyUnreachableCount + per-sink summaries with associated CVEs and a path hash), and analysis metadata (scanner name/version, ruleset, base/head graph digests, code-changes digest). Predicate type stellaops.dev/predicates/reachability-drift@v1.
Drift Alert Schema (DRAFT — not implemented)
DRAFT. No Notify event kind or alert record for reachability drift exists in the source. The shape below is a forward-looking proposal, not a wired contract.
interface DriftAlert {
type: 'reachability_drift';
priority: 'critical' | 'high' | 'medium' | 'low';
title: string;
body: {
summary: string;
cause?: string; // DriftCause.kind
associatedCves?: string[];
impact?: string; // e.g. "Drift gate decision: Block"
action_required?: string;
};
channels: string[];
}
Error Handling
Behaviour grounded in ReachabilityDriftEndpoints and GateEndpoints:
| Condition | Behaviour (source) |
|---|---|
| Invalid / missing scan id | 400 “Invalid scan identifier” |
| Unsupported drift language | 400; the problem detail’s text says “Supported tier-1 languages:” but enumerates the full IDriftLanguageRouter.SupportedLanguages set (5 tier-1 + 5 tier-2) |
| Head/base scan not found | 404 “Scan not found” / “Base scan not found” |
| Call graph snapshot missing | 404 “Base/Head call graph not found” |
baseScanId omitted and no prior result | 404 “Drift result not found” |
| Base/head language mismatch | Detect() throws ArgumentException → 400 “Invalid drift request” |
driftId unknown (sinks endpoint) | 404 “Drift result not found” |
| Gate: no baseline found | BuildNoBaselineResponse per MissingBaselineMode (bootstrap attempts to create one; a Fail status returns 403) |
| Gate: no material drift / gates disabled | Allow decision |
Observability
Tracing & persistence (implemented)
- Activity span:
reachability_drift.attest(inDriftAttestationService) with tagstenant,base_scan,head_scan; status set toErroron failure. - Structured logs (ILogger): drift gate decisions log
"Gate evaluated for {ImageDigest}: decision={Decision}, decisionId={DecisionId}","Drift gate {Gate} blocked drift: {Reason}", and override info;DriftAttestationServicelogs the created attestation digest and newly-reachable/unreachable counts. - Persistence: drift results are stored in the
reachability_drift_resultsPostgres table (ScannerDbContext+001_initial_schema.sql), keyed by areachability_drift_uniqueconstraint over(tenant_id, base_scan_id, head_scan_id, language, result_digest), with indexesidx_reachability_drift_head(tenant_id, head_scan_id, language)and a BRIN indexidx_reachability_drift_detected_atondetected_at.
DRAFT — not implemented. The named metrics (
reachability_drift_events_total,reachability_state_transitions_total,drift_alert_sent_total,drift_detection_latency_ms) and the named log events (reach.state_transition,reach.drift_detected,reach.verdict_changed,reach.alert_sent) do not exist in the source. No dedicated meters/counters for the drift pipeline were found; observability today is the tracing/logging/persistence listed above.
Related Flows
- Policy Evaluation Flow - 8-state reachability lattice details (and the distinct K4 Belnap trust lattice)
- Advisory Drift Re-scan Flow - Similar re-evaluation
- Risk Score Dashboard Flow - Risk impact
- Notification Flow - Alert delivery (note: drift is not yet a wired Notify event kind)
