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:

  1. Scanner reachability drift (implemented). StellaOps.Scanner.ReachabilityDrift compares the call graph of a base scan against a head scan and reports sinks that became reachable / unreachable. Exposed via GET /api/v1/scans/{scanId}/drift and GET /api/v1/drift/{driftId}/sinks on the Scanner WebService (src/Scanner/StellaOps.Scanner.WebService/Endpoints/ReachabilityDriftEndpoints.cs). This is scan-vs-scan diffing — not runtime-vs-baseline state tracking.
  2. Signals runtime updates (implemented). When runtime observations change, Signals emits a RuntimeUpdatedEvent carrying a previous/new reachability-lattice state (as short codes) and a triggerReanalysis flag (src/Signals/StellaOps.Signals/Models/RuntimeUpdatedEvent.cs). The v1 reachability lattice itself lives in src/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@v1 DSSE predicate). A dedicated Notify event kind for reachability drift does not exist (NotifyEventKinds has no reachability.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

ActorTypeRole
SignalsServiceIngests runtime/callgraph signals; maintains v1 reachability-lattice state; emits RuntimeUpdatedEvent with triggerReanalysis
ScannerServiceComputes scan-vs-scan reachability drift (StellaOps.Scanner.ReachabilityDrift); exposes drift + sinks endpoints
ReachGraphServiceContent-addressed reachability graph storage, slice queries, and replay verification (v1/reachgraphs) — not a drift detector
Policy EngineServiceEvaluates the drift gate (DriftGateEvaluator) for release decisions (Allow/Warn/Block)
AttestorServiceSigns reachability-drift@v1 DSSE attestations over the drift result
NotifyServiceDRAFT — alert delivery; no reachability.drift event kind is wired today
Scheduler / JobEngineServiceDRAFT — periodic drift polling is not implemented (drift is on-demand)

Prerequisites

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) in src/Policy/__Libraries/StellaOps.Policy/TrustLattice/K4Lattice.cs (also mirrored in StellaOps.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:

StateCodeMeaning (from source)
UnknownUBottom element — no evidence available
StaticallyReachableSRStatic analysis suggests a path exists
StaticallyUnreachableSUStatic analysis finds no path
RuntimeObservedRORuntime probe/hit confirms execution
RuntimeUnobservedRURuntime probe active but no hit observed
ConfirmedReachableCRStatic and runtime agree reachable (Join(SR, RO))
ConfirmedUnreachableCUStatic and runtime agree unreachable (Join(SU, RU))
ContestedXTop 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 previousStatenewState (as reachability-lattice short codes) and the factory decides whether to set triggerReanalysis (RuntimeUpdatedEventFactory.ShouldTriggerReanalysis):

ConditionReanalysis reason
UpdateType == ExploitTelemetryexploit_telemetry_detected
previousState != newStatestate_change_<from>_to_<to>
fromRuntime and confidence >= 0.9high_confidence_runtime_observation
UpdateType == NewCallPathnew_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 → ToRisk ImpactAlert Priority
U → SRIncreasedMedium
U → ROIncreasedHigh
SU → SRIncreasedMedium
SR → RO / SR → CRConfirmedHigh
SR → SUDecreasedLow
RO → RUDecreasedMedium
any → CUDecreasedLow
any → X (Contested)Review neededHigh

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.EmitRuntimeUpdatedEventAsyncIEventsPublisher) 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#/netdotnet, javascript/ts/nodejsnode). 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.csAddStellaOpsAnyScopePolicy(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:

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-pathdelta_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:

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/Block for the gate decision and Pass/Warned/Blocked (PolicyVerdictStatus, also GuardedPass) for verdict status. The earlier “WARN → FAIL” phrasing matched neither enum; the CI-facing GateStatus is Pass/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) contains scanner.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 is scheduler.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_policy presets (aggressive/balanced/permissive) and drift_detection modes (realtime/batch/hybrid, observation_gap, min_invocations_for_transition) are aspirational and have no counterpart in the source. Use SmartDiff:Gates above.

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 == 0Allow / 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:

ConditionBehaviour (source)
Invalid / missing scan id400 “Invalid scan identifier”
Unsupported drift language400; 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 found404 “Scan not found” / “Base scan not found”
Call graph snapshot missing404 “Base/Head call graph not found”
baseScanId omitted and no prior result404 “Drift result not found”
Base/head language mismatchDetect() throws ArgumentException400 “Invalid drift request”
driftId unknown (sinks endpoint)404 “Drift result not found”
Gate: no baseline foundBuildNoBaselineResponse per MissingBaselineMode (bootstrap attempts to create one; a Fail status returns 403)
Gate: no material drift / gates disabledAllow decision

Observability

Tracing & persistence (implemented)

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.